From 926d0d97d06c635a350959033a1206a9b8344859 Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Thu, 30 Jul 2026 19:06:32 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EC=9B=B9=20=EC=9D=B8=EC=A6=9D?= =?UTF-8?q?=EC=9D=84=20=EB=B3=B4=EC=A1=B4=ED=95=98=EB=A9=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=B0=94=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=EA=B3=84=EC=B8=B5?= =?UTF-8?q?=EC=9D=84=20=EA=B5=AC=ED=98=84=20-=20auth=20=EB=AA=A8=EB=93=88?= =?UTF-8?q?=EC=9D=84=20core,=20device,=20member,=20mobile,=20oauth,=20secu?= =?UTF-8?q?rity,=20ticket,=20token,=20web=20=EA=B2=BD=EA=B3=84=EB=A1=9C=20?= =?UTF-8?q?=EC=9E=AC=EA=B5=AC=EC=84=B1=20-=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=EC=9D=98=20=EC=9D=B8=EC=A6=9D=20=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=EC=99=80=20=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=EC=9D=84=20application=20service=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EB=8F=99=20-=20=EA=B8=B0=EC=A1=B4=20=EC=9B=B9=20HttpOnly=20?= =?UTF-8?q?=EC=BF=A0=ED=82=A4=EC=99=80=20XSRF=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EA=B3=84=EC=95=BD=EC=9D=84=20=EC=9C=A0=EC=A7=80=20-=20PKCE=20S?= =?UTF-8?q?256=20=EA=B8=B0=EB=B0=98=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20OAuth?= =?UTF-8?q?=20transaction=EA=B3=BC=20=EC=9D=BC=ED=9A=8C=EC=9A=A9=20code=20?= =?UTF-8?q?=EA=B5=90=ED=99=98=EC=9D=84=20=EA=B5=AC=ED=98=84=20-=20?= =?UTF-8?q?=EB=AA=A8=EB=B0=94=EC=9D=BC=20login,=20token,=20refresh,=20logo?= =?UTF-8?q?ut,=20signup=20API=EB=A5=BC=20=EC=B6=94=EA=B0=80=20-=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EA=B3=84=EC=A0=95=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=EA=B3=BC=20=EC=84=A4=EC=A0=95=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=86=8C=EC=85=9C=20=EC=B6=94=EA=B0=80=20=EC=97=B0=EB=8F=99?= =?UTF-8?q?=EC=9D=84=20=EC=A7=80=EC=9B=90=20-=20Bearer=20AT=EB=A5=BC=20?= =?UTF-8?q?=EC=9A=B0=EC=84=A0=20=EA=B2=80=EC=A6=9D=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=EA=B0=80=20=EC=97=86=EC=9D=84=20=EB=95=8C?= =?UTF-8?q?=EB=A7=8C=20=EC=9B=B9=20AT=20=EC=BF=A0=ED=82=A4=EB=A1=9C=20fall?= =?UTF-8?q?back=20-=20AT=EC=97=90=20session=20id=EB=A5=BC=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=ED=95=98=EA=B3=A0=20provider=EC=99=80=20session=20id?= =?UTF-8?q?=EB=A5=BC=20=ED=83=80=EC=9E=85=ED=99=94=EB=90=9C=20=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=AC=B8=EB=A7=A5=EC=9C=BC=EB=A1=9C=20=EC=A0=9C?= =?UTF-8?q?=EA=B3=B5=20-=20=EA=B8=B0=EC=A1=B4=20RT=20rotation,=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20refresh=20=EC=88=98=EB=A0=B4=EA=B3=BC=20=EC=9E=AC?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=20=ED=83=90=EC=A7=80=20=EC=A0=95=EC=B1=85?= =?UTF-8?q?=EC=9D=84=20=EB=AA=A8=EB=B0=94=EC=9D=BC=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=8F=84=20=EA=B3=B5=EC=9C=A0=20-=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20guest=20credential=20=EB=B0=9C=EA=B8=89,=20?= =?UTF-8?q?=ED=95=B4=EC=8B=9C=20=EC=A0=80=EC=9E=A5,=20=EB=A7=8C=EB=A3=8C?= =?UTF-8?q?=20=EC=A0=95=EB=A6=AC=EC=99=80=20=EB=B0=9C=EA=B8=89=20=EC=A0=9C?= =?UTF-8?q?=ED=95=9C=EC=9D=84=20=EA=B5=AC=ED=98=84=20-=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EB=90=9C=20Bearer=EC=99=80=20guest=20credential=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EB=A7=8C=20=EC=84=A0=ED=83=9D=EC=A0=81?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20CSRF=EC=97=90=EC=84=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20-=20=EC=8B=A0=EB=A2=B0=20=ED=94=84=EB=A1=9D?= =?UTF-8?q?=EC=8B=9C=20=EC=B2=B4=EC=9D=B8=EA=B3=BC=20HMAC=20=EA=B8=B0?= =?UTF-8?q?=EB=B0=98=20=ED=81=B4=EB=9D=BC=EC=9D=B4=EC=96=B8=ED=8A=B8=20IP?= =?UTF-8?q?=20=EC=8B=9D=EB=B3=84=EC=9D=84=20=EC=A0=81=EC=9A=A9=20-=20OAuth?= =?UTF-8?q?=20code,=20state,=20token=EA=B3=BC=20ticket=EC=9D=84=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EB=A1=9C=EA=B7=B8=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=A7=88=EC=8A=A4=ED=82=B9=20-=20Apple=20OAuth=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=B2=98=EB=A6=AC=EC=99=80=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=20client=20secret=20=EC=83=9D=EC=84=B1=EC=9D=84=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20-=20=EC=9D=B8=EC=A6=9D=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EC=99=80=20=EC=9A=94=EC=B2=AD=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=EB=A5=BC=20=EA=B8=80=EB=A1=9C=EB=B2=8C=20?= =?UTF-8?q?=EC=98=88=EC=99=B8=20=EC=B2=98=EB=A6=AC=EB=A1=9C=20=ED=86=B5?= =?UTF-8?q?=ED=95=A9=20-=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20=EC=8B=A4?= =?UTF-8?q?=EA=B8=B0=EA=B8=B0=EC=9A=A9=20=EC=99=B8=EB=B6=80=20presign=20en?= =?UTF-8?q?dpoint=EB=A5=BC=20=EC=A7=80=EC=9B=90=20-=20=EB=AA=A8=EB=B0=94?= =?UTF-8?q?=EC=9D=BC=20guest=EC=99=80=20Apple=20provider=EC=9A=A9=20?= =?UTF-8?q?=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=B0=8F=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=EB=A5=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/api/GlobalExceptionHandler.java | 25 +- .../app/config/SchedulingConfig.java | 9 + .../V7__add_mobile_guest_credentials.sql | 18 + .../V8__allow_apple_social_provider.sql | 6 + .../app/api/GlobalExceptionHandlerTest.java | 7 +- auth/build.gradle | 2 - .../nalssilog/auth/api/AuthController.java | 182 ------- .../nalssilog/auth/config/AuthProperties.java | 128 ++++- .../auth/config/JwtAuthenticationFilter.java | 48 -- .../config/OAuth2LoginSuccessHandler.java | 176 ------ .../nalssilog/auth/core/AuthErrorCode.java | 50 ++ .../{application => core}/AuthService.java | 191 ++++++- .../RefreshRejectedException.java | 2 +- .../dto => device}/DeviceInfo.java | 2 +- .../DeviceInfoResolver.java | 49 +- .../nalssilog/auth/domain/AuthErrorCode.java | 26 - .../nalssilog/auth/domain/SignupTicket.java | 15 - .../auth/{client => member}/MemberClient.java | 17 +- .../auth/mobile/MobileAuthController.java | 132 +++++ .../auth/mobile/MobileAuthService.java | 104 ++++ .../mobile/MobileAuthorizationResponse.java | 4 + .../auth/mobile/MobileDeviceInfoResolver.java | 31 ++ .../auth/mobile/MobileDeviceRequest.java | 19 + .../auth/mobile/MobileLinkCancelRequest.java | 9 + .../auth/mobile/MobileLinkConsentRequest.java | 13 + .../auth/mobile/MobileLinkStartRequest.java | 12 + .../auth/mobile/MobileLogoutRequest.java | 9 + .../nalssilog/auth/mobile/MobilePlatform.java | 6 + .../auth/mobile/MobileRefreshRequest.java | 12 + .../auth/mobile/MobileRefreshResponse.java | 26 + .../auth/mobile/MobileSignupRequest.java | 15 + .../auth/mobile/MobileTokenRequest.java | 14 + .../auth/mobile/MobileTokenResponse.java | 82 +++ .../mobile/guest/GuestCredentialResponse.java | 13 + .../mobile/guest/MobileGuestController.java | 27 + .../mobile/guest/MobileGuestCredential.java | 70 +++ .../MobileGuestCredentialCleanupService.java | 37 ++ .../guest/MobileGuestCredentialFilter.java | 73 +++ .../MobileGuestCredentialRepository.java | 12 + .../guest/MobileGuestCredentialService.java | 74 +++ .../guest/MobileGuestIssuanceRateLimiter.java | 78 +++ .../auth/mobile/oauth/MobileAuthResult.java | 9 + ...leOAuthAuthorizationRequestRepository.java | 51 ++ ...bileOAuthAuthorizationRequestResolver.java | 114 ++++ .../mobile/oauth/MobileOAuthCodeStore.java | 147 +++++ .../auth/mobile/oauth/MobileOAuthGrant.java | 47 ++ .../auth/mobile/oauth/MobileOAuthPurpose.java | 7 + .../oauth/MobileOAuthRequestAttributes.java | 28 + .../auth/mobile/oauth/MobileOAuthService.java | 514 ++++++++++++++++++ .../mobile/oauth/MobileOAuthTransaction.java | 14 + .../oauth/MobileOAuthTransactionStore.java | 82 +++ .../CustomOAuth2UserService.java | 6 +- .../auth/oauth/CustomOidcUserService.java | 46 ++ .../OAuth2LoginFailureHandler.java | 39 +- .../auth/oauth/OAuth2LoginSuccessHandler.java | 106 ++++ .../auth/{client => oauth}/OAuthUserInfo.java | 24 +- .../SocialAuthPrincipal.java | 9 +- .../auth/oauth/SocialOidcPrincipal.java | 62 +++ .../nalssilog/auth/oauth/SocialPrincipal.java | 14 + .../nalssilog/auth/oauth/WebOAuthService.java | 254 +++++++++ .../AppleAuthorizationResponseFilter.java | 108 ++++ .../apple/AppleAuthorizationUserContext.java | 26 + .../apple/AppleClientSecretGenerator.java | 130 +++++ .../auth/oauth/apple/AppleOAuthConfig.java | 43 ++ .../apple/AppleOAuthProfileValidator.java | 19 + .../oauth/apple/AppleOAuthProperties.java | 70 +++ .../auth/repository/AuthTicketStore.java | 99 ---- .../security/ApiAuthenticationEntryPoint.java | 45 ++ .../auth/security/AuthNoStoreFilter.java | 38 ++ .../AuthSecurityConfig.java | 115 +++- .../CredentialAuthenticationException.java | 25 + .../CsrfCookieFilter.java | 2 +- .../SpaCsrfTokenRequestHandler.java | 2 +- .../nalssilog/auth/ticket/AuthChannel.java | 6 + .../auth/ticket/AuthTicketStore.java | 235 ++++++++ .../auth/{domain => ticket}/LinkTicket.java | 20 +- .../nalssilog/auth/ticket/SignupTicket.java | 30 + .../auth/token/AuthRequestDetails.java | 10 + .../AuthSessionService.java | 19 +- .../AuthTokenService.java | 62 ++- .../auth/token/CredentialTransport.java | 6 + .../auth/token/JwtAuthenticationFilter.java | 164 ++++++ .../JwtTokenProvider.java | 86 ++- .../RefreshTokenStore.java | 32 +- .../dto => token}/SessionData.java | 2 +- .../dto => token}/SessionView.java | 2 +- .../{application => token}/TokenPair.java | 2 +- .../nalssilog/auth/web/AuthController.java | 178 ++++++ .../{config => web}/AuthCookieManager.java | 11 +- .../auth/{api/dto => web}/AuthResult.java | 2 +- .../{api/dto => web}/LinkConsentResponse.java | 2 +- .../auth/{api/dto => web}/MeResponse.java | 8 +- .../{api/dto => web}/SessionResponse.java | 5 +- .../auth/{api/dto => web}/SignupRequest.java | 2 +- .../auth/core/AuthServiceSignupTest.java | 129 +++++ ...bileGuestCredentialCleanupServiceTest.java | 55 ++ .../MobileGuestCredentialServiceTest.java | 78 +++ .../MobileGuestIssuanceRateLimiterTest.java | 119 ++++ ...OAuthAuthorizationRequestResolverTest.java | 55 ++ .../mobile/oauth/MobileOAuthServiceTest.java | 244 +++++++++ .../CustomOAuth2UserServiceTest.java | 2 +- .../OAuth2LoginFailureHandlerTest.java | 10 +- .../{client => oauth}/OAuthUserInfoTest.java | 29 +- .../AppleAuthorizationResponseFilterTest.java | 48 ++ .../apple/AppleClientSecretGeneratorTest.java | 58 ++ .../AuthTokenServiceTest.java | 24 +- .../token/JwtAuthenticationFilterTest.java | 143 +++++ .../JwtTokenProviderTest.java | 21 +- .../RefreshTokenStoreTest.java | 6 +- .../auth/token/TestAuthProperties.java | 28 + .../auth/{api => web}/AuthControllerTest.java | 36 +- common/build.gradle | 1 + .../common/filter/RequestLoggingFilter.java | 214 +++++--- .../common/security/SecretFingerprint.java | 41 ++ .../security/VerifiedRequestCredentials.java | 50 ++ .../common/web/TrustedProxyChain.java | 124 +++++ .../filter/RequestLoggingFilterTest.java | 25 + .../member/api/MemberController.java | 29 +- .../application/FeedbackRateLimiter.java | 25 +- .../application/MemberAccountService.java | 19 +- .../config/TrustedProxyClientIpResolver.java | 87 +-- .../com/nalssilog/member/domain/Provider.java | 3 +- .../report/config/ReportActorResolver.java | 25 +- .../config/ReportActorResolverTest.java | 49 ++ .../com/nalssilog/storage/StorageConfig.java | 3 +- .../nalssilog/storage/StorageProperties.java | 20 +- 126 files changed, 5886 insertions(+), 918 deletions(-) create mode 100644 app/src/main/java/com/nalssilog/app/config/SchedulingConfig.java create mode 100644 app/src/main/resources/db/migration/V7__add_mobile_guest_credentials.sql create mode 100644 app/src/main/resources/db/migration/V8__allow_apple_social_provider.sql delete mode 100644 auth/src/main/java/com/nalssilog/auth/api/AuthController.java delete mode 100644 auth/src/main/java/com/nalssilog/auth/config/JwtAuthenticationFilter.java delete mode 100644 auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginSuccessHandler.java create mode 100644 auth/src/main/java/com/nalssilog/auth/core/AuthErrorCode.java rename auth/src/main/java/com/nalssilog/auth/{application => core}/AuthService.java (57%) rename auth/src/main/java/com/nalssilog/auth/{domain => core}/RefreshRejectedException.java (92%) rename auth/src/main/java/com/nalssilog/auth/{application/dto => device}/DeviceInfo.java (86%) rename auth/src/main/java/com/nalssilog/auth/{config => device}/DeviceInfoResolver.java (80%) delete mode 100644 auth/src/main/java/com/nalssilog/auth/domain/AuthErrorCode.java delete mode 100644 auth/src/main/java/com/nalssilog/auth/domain/SignupTicket.java rename auth/src/main/java/com/nalssilog/auth/{client => member}/MemberClient.java (84%) create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthController.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthService.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthorizationResponse.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceInfoResolver.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceRequest.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkCancelRequest.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkConsentRequest.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkStartRequest.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileLogoutRequest.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobilePlatform.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshRequest.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshResponse.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileSignupRequest.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenRequest.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/guest/GuestCredentialResponse.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestController.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredential.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupService.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialFilter.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialRepository.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialService.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiter.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileAuthResult.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthCodeStore.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthGrant.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthPurpose.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthRequestAttributes.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransaction.java create mode 100644 auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransactionStore.java rename auth/src/main/java/com/nalssilog/auth/{application => oauth}/CustomOAuth2UserService.java (93%) create mode 100644 auth/src/main/java/com/nalssilog/auth/oauth/CustomOidcUserService.java rename auth/src/main/java/com/nalssilog/auth/{config => oauth}/OAuth2LoginFailureHandler.java (54%) create mode 100644 auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java rename auth/src/main/java/com/nalssilog/auth/{client => oauth}/OAuthUserInfo.java (74%) rename auth/src/main/java/com/nalssilog/auth/{application => oauth}/SocialAuthPrincipal.java (92%) create mode 100644 auth/src/main/java/com/nalssilog/auth/oauth/SocialOidcPrincipal.java create mode 100644 auth/src/main/java/com/nalssilog/auth/oauth/SocialPrincipal.java create mode 100644 auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java create mode 100644 auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilter.java create mode 100644 auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationUserContext.java create mode 100644 auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGenerator.java create mode 100644 auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthConfig.java create mode 100644 auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProfileValidator.java create mode 100644 auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProperties.java delete mode 100644 auth/src/main/java/com/nalssilog/auth/repository/AuthTicketStore.java create mode 100644 auth/src/main/java/com/nalssilog/auth/security/ApiAuthenticationEntryPoint.java create mode 100644 auth/src/main/java/com/nalssilog/auth/security/AuthNoStoreFilter.java rename auth/src/main/java/com/nalssilog/auth/{config => security}/AuthSecurityConfig.java (56%) create mode 100644 auth/src/main/java/com/nalssilog/auth/security/CredentialAuthenticationException.java rename auth/src/main/java/com/nalssilog/auth/{config => security}/CsrfCookieFilter.java (96%) rename auth/src/main/java/com/nalssilog/auth/{config => security}/SpaCsrfTokenRequestHandler.java (97%) create mode 100644 auth/src/main/java/com/nalssilog/auth/ticket/AuthChannel.java create mode 100644 auth/src/main/java/com/nalssilog/auth/ticket/AuthTicketStore.java rename auth/src/main/java/com/nalssilog/auth/{domain => ticket}/LinkTicket.java (55%) create mode 100644 auth/src/main/java/com/nalssilog/auth/ticket/SignupTicket.java create mode 100644 auth/src/main/java/com/nalssilog/auth/token/AuthRequestDetails.java rename auth/src/main/java/com/nalssilog/auth/{application => token}/AuthSessionService.java (75%) rename auth/src/main/java/com/nalssilog/auth/{application => token}/AuthTokenService.java (90%) create mode 100644 auth/src/main/java/com/nalssilog/auth/token/CredentialTransport.java create mode 100644 auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java rename auth/src/main/java/com/nalssilog/auth/{application => token}/JwtTokenProvider.java (51%) rename auth/src/main/java/com/nalssilog/auth/{repository => token}/RefreshTokenStore.java (99%) rename auth/src/main/java/com/nalssilog/auth/{application/dto => token}/SessionData.java (92%) rename auth/src/main/java/com/nalssilog/auth/{application/dto => token}/SessionView.java (88%) rename auth/src/main/java/com/nalssilog/auth/{application => token}/TokenPair.java (85%) create mode 100644 auth/src/main/java/com/nalssilog/auth/web/AuthController.java rename auth/src/main/java/com/nalssilog/auth/{config => web}/AuthCookieManager.java (98%) rename auth/src/main/java/com/nalssilog/auth/{api/dto => web}/AuthResult.java (86%) rename auth/src/main/java/com/nalssilog/auth/{api/dto => web}/LinkConsentResponse.java (84%) rename auth/src/main/java/com/nalssilog/auth/{api/dto => web}/MeResponse.java (96%) rename auth/src/main/java/com/nalssilog/auth/{api/dto => web}/SessionResponse.java (88%) rename auth/src/main/java/com/nalssilog/auth/{api/dto => web}/SignupRequest.java (94%) create mode 100644 auth/src/test/java/com/nalssilog/auth/core/AuthServiceSignupTest.java create mode 100644 auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupServiceTest.java create mode 100644 auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialServiceTest.java create mode 100644 auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiterTest.java create mode 100644 auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolverTest.java create mode 100644 auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java rename auth/src/test/java/com/nalssilog/auth/{application => oauth}/CustomOAuth2UserServiceTest.java (97%) rename auth/src/test/java/com/nalssilog/auth/{config => oauth}/OAuth2LoginFailureHandlerTest.java (87%) rename auth/src/test/java/com/nalssilog/auth/{client => oauth}/OAuthUserInfoTest.java (72%) create mode 100644 auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilterTest.java create mode 100644 auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGeneratorTest.java rename auth/src/test/java/com/nalssilog/auth/{application => token}/AuthTokenServiceTest.java (93%) create mode 100644 auth/src/test/java/com/nalssilog/auth/token/JwtAuthenticationFilterTest.java rename auth/src/test/java/com/nalssilog/auth/{application => token}/JwtTokenProviderTest.java (69%) rename auth/src/test/java/com/nalssilog/auth/{repository => token}/RefreshTokenStoreTest.java (93%) create mode 100644 auth/src/test/java/com/nalssilog/auth/token/TestAuthProperties.java rename auth/src/test/java/com/nalssilog/auth/{api => web}/AuthControllerTest.java (91%) create mode 100644 common/src/main/java/com/nalssilog/common/security/SecretFingerprint.java create mode 100644 common/src/main/java/com/nalssilog/common/security/VerifiedRequestCredentials.java create mode 100644 common/src/main/java/com/nalssilog/common/web/TrustedProxyChain.java create mode 100644 common/src/test/java/com/nalssilog/common/filter/RequestLoggingFilterTest.java create mode 100644 report/src/test/java/com/nalssilog/report/config/ReportActorResolverTest.java diff --git a/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java b/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java index ce3a02f..561d9b7 100644 --- a/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java +++ b/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java @@ -1,19 +1,23 @@ package com.nalssilog.app.api; -import com.nalssilog.auth.config.AuthCookieManager; -import com.nalssilog.auth.domain.RefreshRejectedException; +import com.nalssilog.auth.core.RefreshRejectedException; +import com.nalssilog.auth.web.AuthCookieManager; import com.nalssilog.common.exception.ErrorCode; import com.nalssilog.common.exception.ErrorResponse; import com.nalssilog.common.exception.NalssiLogException; import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.ConstraintViolationException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.converter.HttpMessageNotReadableException; import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingServletRequestParameterException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.servlet.resource.NoResourceFoundException; @Slf4j @@ -33,6 +37,7 @@ public ErrorResponse handleNalssiLogException( } ErrorCode errorCode = exception.getErrorCode(); + response.setStatus(errorCode.getStatus().value()); log.warn("NalssiLogException [{}] {} (status={})", errorCode.getCode(), exception.getMessage(), errorCode.getStatus().value()); @@ -51,15 +56,31 @@ public ErrorResponse handleValidationException(MethodArgumentNotValidException e return new ErrorResponse("VALIDATION_ERROR", message); } + @ResponseStatus(HttpStatus.BAD_REQUEST) + @ExceptionHandler({ + ConstraintViolationException.class, + HandlerMethodValidationException.class, + MissingServletRequestParameterException.class, + MethodArgumentTypeMismatchException.class + }) + public ErrorResponse handleRequestParameterValidation(Exception exception) { + + return new ErrorResponse( + "VALIDATION_ERROR", + "요청 파라미터가 올바르지 않습니다."); + } + @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(HttpMessageNotReadableException.class) public ErrorResponse handleNotReadable(HttpMessageNotReadableException exception) { + return new ErrorResponse("INVALID_REQUEST", "요청 본문을 해석할 수 없습니다."); } @ResponseStatus(HttpStatus.NOT_FOUND) @ExceptionHandler(NoResourceFoundException.class) public ErrorResponse handleNoResourceFoundException(NoResourceFoundException exception) { + return new ErrorResponse("NOT_FOUND", "요청한 리소스를 찾을 수 없습니다."); } diff --git a/app/src/main/java/com/nalssilog/app/config/SchedulingConfig.java b/app/src/main/java/com/nalssilog/app/config/SchedulingConfig.java new file mode 100644 index 0000000..7ff3b38 --- /dev/null +++ b/app/src/main/java/com/nalssilog/app/config/SchedulingConfig.java @@ -0,0 +1,9 @@ +package com.nalssilog.app.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/app/src/main/resources/db/migration/V7__add_mobile_guest_credentials.sql b/app/src/main/resources/db/migration/V7__add_mobile_guest_credentials.sql new file mode 100644 index 0000000..352a792 --- /dev/null +++ b/app/src/main/resources/db/migration/V7__add_mobile_guest_credentials.sql @@ -0,0 +1,18 @@ +CREATE TABLE mobile_guest_credential ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + created_at timestamp(6) with time zone NOT NULL, + updated_at timestamp(6) with time zone NOT NULL, + token_hash character varying(64) NOT NULL, + anonymous_key character varying(36) NOT NULL, + expires_at timestamp(6) with time zone NOT NULL, + revoked_at timestamp(6) with time zone +); + +ALTER TABLE mobile_guest_credential + ADD CONSTRAINT uk_mobile_guest_token_hash UNIQUE (token_hash); + +ALTER TABLE mobile_guest_credential + ADD CONSTRAINT uk_mobile_guest_anonymous_key UNIQUE (anonymous_key); + +CREATE INDEX idx_mobile_guest_expires_at + ON mobile_guest_credential (expires_at); diff --git a/app/src/main/resources/db/migration/V8__allow_apple_social_provider.sql b/app/src/main/resources/db/migration/V8__allow_apple_social_provider.sql new file mode 100644 index 0000000..efa69f6 --- /dev/null +++ b/app/src/main/resources/db/migration/V8__allow_apple_social_provider.sql @@ -0,0 +1,6 @@ +ALTER TABLE social_account + DROP CONSTRAINT social_account_provider_check; + +ALTER TABLE social_account + ADD CONSTRAINT social_account_provider_check + CHECK (provider IN ('GOOGLE', 'KAKAO', 'NAVER', 'APPLE')); diff --git a/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java b/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java index d24656d..b98d94b 100644 --- a/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java +++ b/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java @@ -2,10 +2,10 @@ import static org.assertj.core.api.Assertions.assertThat; -import com.nalssilog.auth.config.AuthCookieManager; import com.nalssilog.auth.config.AuthProperties; -import com.nalssilog.auth.domain.AuthErrorCode; -import com.nalssilog.auth.domain.RefreshRejectedException; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.core.RefreshRejectedException; +import com.nalssilog.auth.web.AuthCookieManager; import com.nalssilog.common.exception.ErrorResponse; import com.nalssilog.common.exception.NalssiLogException; import java.time.Duration; @@ -55,6 +55,7 @@ void refreshRejectionDeletesBothAuthenticationCookies() { } private AuthProperties properties() { + return new AuthProperties( new AuthProperties.Jwt( "test-secret-must-be-at-least-thirty-two-bytes", diff --git a/auth/build.gradle b/auth/build.gradle index 6561fe6..b08238a 100644 --- a/auth/build.gradle +++ b/auth/build.gradle @@ -5,8 +5,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'com.fasterxml.jackson.core:jackson-databind' - implementation 'io.jsonwebtoken:jjwt-api:0.13.0' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' diff --git a/auth/src/main/java/com/nalssilog/auth/api/AuthController.java b/auth/src/main/java/com/nalssilog/auth/api/AuthController.java deleted file mode 100644 index d4e00f0..0000000 --- a/auth/src/main/java/com/nalssilog/auth/api/AuthController.java +++ /dev/null @@ -1,182 +0,0 @@ -package com.nalssilog.auth.api; - -import java.io.IOException; -import java.util.List; - -import org.springframework.http.HttpStatus; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.ResponseStatus; -import org.springframework.web.bind.annotation.RestController; - -import com.nalssilog.auth.api.dto.LinkConsentResponse; -import com.nalssilog.auth.api.dto.MeResponse; -import com.nalssilog.auth.api.dto.SessionResponse; -import com.nalssilog.auth.api.dto.SignupRequest; -import com.nalssilog.auth.application.AuthService; -import com.nalssilog.auth.application.AuthService.SignupResult; -import com.nalssilog.auth.application.AuthService.SocialLinkStart; -import com.nalssilog.auth.application.TokenPair; -import com.nalssilog.auth.config.AuthCookieManager; -import com.nalssilog.auth.config.DeviceInfoResolver; - -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/api/auth") -@RequiredArgsConstructor -public class AuthController { - - private final AuthService authService; - private final AuthCookieManager cookieManager; - private final DeviceInfoResolver deviceInfoResolver; - - /** 소셜 로그인 진입. 내부 Spring OAuth 경로를 은닉하고 302 시킨다. */ - @GetMapping("/login/{provider}") - public void login( - @PathVariable String provider, - HttpServletResponse response - ) throws IOException { - response.sendRedirect(authService.oauthAuthorizationUrl(provider)); - } - - /** 인증 상태 조회(stateless — AT/티켓 쿠키로 판단). */ - @GetMapping("/me") - public MeResponse me( - @AuthenticationPrincipal Long memberId, - HttpServletRequest request - ) { - boolean hasAuthenticationCookie = cookieManager.readAccessToken(request).isPresent() - || cookieManager.readRefreshToken(request).isPresent(); - - return MeResponse.from(authService.me( - memberId, - cookieManager.readSignupTicket(request), - cookieManager.readLinkTicket(request), - hasAuthenticationCookie)); - } - - /** 회원가입 확정. OAuth 정보는 signup 티켓에서 읽고, Member 는 여기서 처음 생성된다. */ - @PostMapping("/signup") - public MeResponse signup( - @Valid @RequestBody SignupRequest request, - HttpServletRequest httpRequest, - HttpServletResponse response - ) { - SignupResult result = authService.signup(cookieManager.readSignupTicket(httpRequest).orElse(null), - request.agreedTerms(), deviceInfoResolver.resolve(httpRequest)); - TokenPair tokens = result.tokens(); - - cookieManager.addAuthCookies(response, tokens.accessToken(), tokens.refreshToken(), - tokens.refreshTokenMaxAge()); - cookieManager.clearSignupTicketCookie(response); - - return MeResponse.authenticated(result.member()); - } - - @PostMapping("/refresh") - public void refresh( - HttpServletRequest request, - HttpServletResponse response - ) { - TokenPair tokens = authService.refresh( - cookieManager.readRefreshToken(request).orElse(null), - deviceInfoResolver.resolve(request)); - - cookieManager.addAuthCookies( - response, tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenMaxAge()); - } - - @PostMapping("/logout") - public void logout( - HttpServletRequest request, - HttpServletResponse response - ) { - authService.logout(cookieManager.readRefreshToken(request)); - cookieManager.clearAuthCookies(response); - } - - /** 회원 탈퇴: 익명화(member) + 제보 익명화(event) + 전 기기 세션 만료 + 쿠키 정리. 세션·쿠키 때문에 auth 소유. */ - @DeleteMapping("/withdraw") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void withdraw( - @AuthenticationPrincipal Long memberId, - HttpServletResponse response - ) { - authService.withdraw(memberId); - cookieManager.clearAuthCookies(response); - } - - /** 로그인된 기기 목록(current=이 기기). */ - @GetMapping("/sessions") - public List sessions( - @AuthenticationPrincipal Long memberId, - HttpServletRequest request - ) { - return authService.sessions(memberId, cookieManager.readRefreshToken(request)).stream() - .map(SessionResponse::from) - .toList(); - } - - /** 특정 기기 로그아웃. 대상이 현재 기기면 인증 쿠키까지 정리한다. */ - @DeleteMapping("/sessions/{sessionId}") - @ResponseStatus(HttpStatus.NO_CONTENT) - public void revokeSession( - @AuthenticationPrincipal Long memberId, - @PathVariable String sessionId, - HttpServletRequest request, - HttpServletResponse response - ) { - boolean revokedCurrent = authService.revokeSession( - memberId, sessionId, cookieManager.readRefreshToken(request)); - - if (revokedCurrent) { - cookieManager.clearAuthCookies(response); - } - } - - /** 설정에서 소셜 추가 연동 시작. intent 세팅 후 OAuth 진입 URL 반환(실제 연동은 성공 핸들러). */ - @PostMapping("/link/social/{provider}") - public LinkConsentResponse startSocialLink( - @AuthenticationPrincipal Long memberId, - @PathVariable String provider, - HttpServletResponse response - ) { - SocialLinkStart link = authService.startSocialLink(memberId, provider); - cookieManager.addLinkIntentCookie(response, link.intentId()); - - return new LinkConsentResponse(link.authorizationUrl()); - } - - /** 로그인-시점 연동 동의 + 기존 수단 재인증 URL 반환. */ - @PostMapping("/link/consent") - public LinkConsentResponse consentLink(HttpServletRequest request) { - return new LinkConsentResponse(authService.consentLink( - cookieManager.readLinkTicket(request).orElse(null))); - } - - @GetMapping("/link/reauth/{provider}") - public void linkReauth( - @PathVariable String provider, - HttpServletResponse response - ) throws IOException { - response.sendRedirect(authService.oauthAuthorizationUrl(provider)); - } - - @PostMapping("/link/cancel") - public void cancelLink( - HttpServletRequest request, - HttpServletResponse response - ) { - authService.cancelLink(cookieManager.readLinkTicket(request)); - cookieManager.clearLinkTicketCookie(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 3a60eaa..9523367 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/AuthProperties.java +++ b/auth/src/main/java/com/nalssilog/auth/config/AuthProperties.java @@ -1,15 +1,46 @@ package com.nalssilog.auth.config; import java.time.Duration; +import java.util.List; import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.bind.ConstructorBinding; @ConfigurationProperties(prefix = "nalssilog.auth") -public record AuthProperties(Jwt jwt, Cookie cookie, Ticket ticket, Csrf csrf, Refresh refresh) { +public record AuthProperties( + Jwt jwt, + Cookie cookie, + Ticket ticket, + Csrf csrf, + Refresh refresh, + Mobile mobile, + Guest guest +) { + @ConstructorBinding public AuthProperties { if (refresh == null) { refresh = new Refresh(Duration.ofSeconds(5)); } + + String ipHmacFallback = jwt != null + && jwt.secret() != null + && !jwt.secret().isBlank() + ? jwt.secret() + : "local-mobile-auth-ip-key"; + + if (mobile == null) { + mobile = Mobile.defaults(ipHmacFallback); + } else if (mobile.ipHmacSecret() == null + || mobile.ipHmacSecret().isBlank()) { + mobile = mobile.withIpHmacSecret(ipHmacFallback); + } + if (guest == null) { + guest = Guest.defaults(); + } + } + + public AuthProperties(Jwt jwt, Cookie cookie, Ticket ticket, Csrf csrf, Refresh refresh) { + this(jwt, cookie, ticket, csrf, refresh, null, Guest.defaults()); } public record Jwt(String secret, Duration accessTokenTtl, Duration refreshTokenTtl) { @@ -35,4 +66,99 @@ public record Refresh(Duration retryGrace) { } } } + + public record Mobile( + List redirectUris, + Duration transactionTtl, + Duration codeTtl, + String ipHmacSecret, + List trustedProxies + ) { + + public Mobile { + redirectUris = redirectUris == null || redirectUris.isEmpty() + ? List.of() + : List.copyOf(redirectUris); + if (transactionTtl == null || transactionTtl.isNegative() || transactionTtl.isZero()) { + transactionTtl = Duration.ofMinutes(10); + } + if (codeTtl == null || codeTtl.isNegative() || codeTtl.isZero()) { + codeTtl = Duration.ofSeconds(90); + } + trustedProxies = trustedProxies == null || trustedProxies.isEmpty() + ? List.of("127.0.0.0/8", "::1/128", "172.16.0.0/12") + : List.copyOf(trustedProxies); + } + + private Mobile withIpHmacSecret(String secret) { + + return new Mobile( + redirectUris, + transactionTtl, + codeTtl, + secret, + trustedProxies); + } + + private static Mobile defaults(String ipHmacSecret) { + + return new Mobile(null, null, null, ipHmacSecret, null); + } + } + + public record Guest( + Duration ttl, + int maxIssuances, + Duration rateLimitWindow, + int globalMaxIssuances, + Duration globalRateLimitWindow, + Duration expiredRetention, + Duration cleanupInterval, + Duration cleanupInitialDelay + ) { + + public Guest { + if (ttl == null || ttl.isNegative() || ttl.isZero()) { + ttl = Duration.ofDays(365); + } + if (maxIssuances <= 0) { + maxIssuances = 300; + } + if (rateLimitWindow == null || rateLimitWindow.isNegative() || rateLimitWindow.isZero()) { + rateLimitWindow = Duration.ofMinutes(10); + } + if (globalMaxIssuances <= 0) { + globalMaxIssuances = 3_000; + } + if (globalRateLimitWindow == null + || globalRateLimitWindow.isNegative() + || globalRateLimitWindow.isZero()) { + globalRateLimitWindow = Duration.ofMinutes(1); + } + if (expiredRetention == null || expiredRetention.isNegative()) { + expiredRetention = Duration.ofDays(7); + } + if (cleanupInterval == null || cleanupInterval.isNegative() || cleanupInterval.isZero()) { + cleanupInterval = Duration.ofHours(6); + } + if (cleanupInitialDelay == null + || cleanupInitialDelay.isNegative() + || cleanupInitialDelay.isZero()) { + cleanupInitialDelay = Duration.ofMinutes(1); + } + } + + public Guest( + Duration ttl, + int maxIssuances, + Duration rateLimitWindow + ) { + this(ttl, maxIssuances, rateLimitWindow, 0, null, null, null, null); + } + + private static Guest defaults() { + + return new Guest(null, 0, null, 0, null, null, null, null); + } + } } diff --git a/auth/src/main/java/com/nalssilog/auth/config/JwtAuthenticationFilter.java b/auth/src/main/java/com/nalssilog/auth/config/JwtAuthenticationFilter.java deleted file mode 100644 index 7910d91..0000000 --- a/auth/src/main/java/com/nalssilog/auth/config/JwtAuthenticationFilter.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.nalssilog.auth.config; - -import java.io.IOException; -import java.util.List; - -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -import com.nalssilog.auth.application.JwtTokenProvider; -import com.nalssilog.auth.application.SocialAuthPrincipal; -import com.nalssilog.common.filter.RequestLoggingFilter; -import com.nalssilog.member.domain.MemberStatus; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import lombok.RequiredArgsConstructor; - -@Component -@RequiredArgsConstructor -public class JwtAuthenticationFilter extends OncePerRequestFilter { - - private final AuthCookieManager cookieManager; - private final JwtTokenProvider jwtTokenProvider; - - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) - throws ServletException, IOException { - cookieManager.readAccessToken(request) - .flatMap(jwtTokenProvider::parse) - .filter(payload -> payload.status() != MemberStatus.WITHDRAWN) - .ifPresent(payload -> { - var authentication = UsernamePasswordAuthenticationToken.authenticated( - payload.memberId(), - null, - List.of(new SimpleGrantedAuthority(SocialAuthPrincipal.roleOf(payload.status())))); - authentication.setDetails(payload.provider()); - SecurityContextHolder.getContext().setAuthentication(authentication); - // STATELESS 라 체인 종료 후 SecurityContext 가 비므로, 액세스 로그용으로 memberId 를 남겨둔다. - request.setAttribute(RequestLoggingFilter.ACTOR_MEMBER_ID, payload.memberId()); - }); - filterChain.doFilter(request, response); - } -} diff --git a/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginSuccessHandler.java b/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginSuccessHandler.java deleted file mode 100644 index 705a55b..0000000 --- a/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginSuccessHandler.java +++ /dev/null @@ -1,176 +0,0 @@ -package com.nalssilog.auth.config; - -import com.nalssilog.auth.application.AuthTokenService; -import com.nalssilog.auth.application.SocialAuthPrincipal; -import com.nalssilog.auth.application.TokenPair; -import com.nalssilog.auth.client.MemberClient; -import com.nalssilog.auth.client.OAuthUserInfo; -import com.nalssilog.auth.domain.LinkTicket; -import com.nalssilog.auth.domain.SignupTicket; -import com.nalssilog.auth.repository.AuthTicketStore; -import com.nalssilog.common.exception.NalssiLogException; -import com.nalssilog.member.application.dto.MemberInfo; -import com.nalssilog.member.domain.MemberStatus; -import com.nalssilog.member.domain.Provider; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.Objects; -import java.util.Optional; -import java.util.UUID; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.core.Authentication; -import org.springframework.security.web.authentication.AuthenticationSuccessHandler; -import org.springframework.stereotype.Component; - -/** 소셜 인증 성공 후 항상 `{front}/auth/callback?result=...` 로 리다이렉트. 토큰·민감정보는 URL 에 안 넣고 HttpOnly 쿠키만. */ -@Component -@RequiredArgsConstructor -public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { - - private static final String LINK_FAILED = "LINK_FAILED"; - - private final AuthTokenService authTokenService; - private final AuthCookieManager cookieManager; - private final AuthTicketStore ticketStore; - private final MemberClient memberClient; - private final AuthProperties properties; - private final DeviceInfoResolver deviceInfoResolver; - - @Value("${nalssilog.frontend.base-url}") - private String frontendBaseUrl; - - @Override - public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, - Authentication authentication) throws IOException { - SocialAuthPrincipal principal = - Objects.requireNonNull((SocialAuthPrincipal) authentication.getPrincipal(), "principal must not be null"); - - // 설정 화면에서 시작한 '소셜 추가 연동'이면 로그인 분기보다 먼저 처리한다(현재 회원에 새 소셜 연동). - Optional linkIntent = cookieManager.readLinkIntent(request); - if (linkIntent.isPresent()) { - handleSettingsLink(response, linkIntent.get(), principal); - return; - } - - switch (principal.result().outcome()) { - case EXISTING -> handleExisting(request, response, principal); - case NEW -> handleNew(response, principal); - case LINK_REQUIRED -> handleLinkRequired(response, principal); - } - } - - // 설정에서 로그인 상태로 새 소셜 추가 연동. intent 의 memberId 에 붙이고 기존 세션 유지(토큰 재발급 없음). - private void handleSettingsLink(HttpServletResponse response, String intentId, SocialAuthPrincipal principal) - throws IOException { - Optional memberId = ticketStore.findLinkIntent(intentId); - - cookieManager.clearLinkIntentCookie(response); - ticketStore.deleteLinkIntent(intentId); - - if (memberId.isEmpty()) { - response.sendRedirect(callbackUrl(LINK_FAILED)); - return; - } - - try { - memberClient.linkSocial(memberId.get(), principal.userInfo()); - response.sendRedirect(callbackUrl("LINK_SUCCESS")); - } catch (NalssiLogException e) { - response.sendRedirect(callbackUrl(LINK_FAILED) + "&code=" + e.getErrorCode().getCode()); - } - } - - private void handleExisting(HttpServletRequest request, HttpServletResponse response, - SocialAuthPrincipal principal) throws IOException { - Long memberId = principal.result().memberId(); - MemberStatus status = principal.result().status(); - - if (status == MemberStatus.WITHDRAWN) { - response.sendRedirect(callbackUrl("FAILED")); - return; - } - - Optional pendingLink = cookieManager.readLinkTicket(request); - - if (pendingLink.isPresent()) { - completeLink( - request, response, pendingLink.get(), memberId, status, principal.userInfo().provider()); - return; - } - - loginAndRedirect( - request, response, memberId, status, principal.userInfo().provider(), "SUCCESS"); - } - - // 연동 완료 시도. 명시 동의 + 대상 일치일 때만 실제 연동(방치/미동의 티켓 자동연동 방지), 아니면 일반 로그인. - private void completeLink(HttpServletRequest request, HttpServletResponse response, String ticketId, - Long authenticatedMemberId, MemberStatus status, Provider authenticatedProvider) - throws IOException { - LinkTicket ticket = ticketStore.findLink(ticketId).orElse(null); - boolean consented = ticketStore.isLinkConsented(ticketId); - - cookieManager.clearLinkTicketCookie(response); - ticketStore.deleteLink(ticketId); - ticketStore.deleteLinkConsent(ticketId); - - if (ticket == null || !consented) { - loginAndRedirect( - request, response, authenticatedMemberId, status, authenticatedProvider, "SUCCESS"); - return; - } - - if (!ticket.targetMemberId().equals(authenticatedMemberId)) { - response.sendRedirect(callbackUrl(LINK_FAILED)); - return; - } - - MemberInfo member = memberClient.linkSocial( - ticket.targetMemberId(), - new OAuthUserInfo(ticket.provider(), ticket.providerUserId(), ticket.email(), null)); - - loginAndRedirect( - request, response, member.id(), member.status(), authenticatedProvider, "LINK_SUCCESS"); - } - - private void handleNew(HttpServletResponse response, SocialAuthPrincipal principal) throws IOException { - OAuthUserInfo userInfo = principal.userInfo(); - String ticketId = UUID.randomUUID().toString(); - - ticketStore.saveSignup(ticketId, - new SignupTicket(userInfo.provider(), userInfo.providerUserId(), userInfo.email(), userInfo.socialName()), - properties.ticket().ttl()); - cookieManager.addSignupTicketCookie(response, ticketId); - - response.sendRedirect(callbackUrl("SIGNUP_REQUIRED")); - } - - private void handleLinkRequired(HttpServletResponse response, SocialAuthPrincipal principal) throws IOException { - OAuthUserInfo userInfo = principal.userInfo(); - Long targetMemberId = principal.result().memberId(); - String ticketId = UUID.randomUUID().toString(); - - ticketStore.saveLink(ticketId, - new LinkTicket(userInfo.provider(), userInfo.providerUserId(), userInfo.email(), targetMemberId, - principal.result().existingProviders()), - properties.ticket().ttl()); - cookieManager.addLinkTicketCookie(response, ticketId); - - response.sendRedirect(callbackUrl("LINK_REQUIRED")); - } - - private void loginAndRedirect(HttpServletRequest request, HttpServletResponse response, Long memberId, - 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(), tokens.refreshTokenMaxAge()); - - response.sendRedirect(callbackUrl(result)); - } - - private String callbackUrl(String result) { - return frontendBaseUrl + "/auth/callback?result=" + result; - } -} diff --git a/auth/src/main/java/com/nalssilog/auth/core/AuthErrorCode.java b/auth/src/main/java/com/nalssilog/auth/core/AuthErrorCode.java new file mode 100644 index 0000000..653aa3f --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/core/AuthErrorCode.java @@ -0,0 +1,50 @@ +package com.nalssilog.auth.core; + +import com.nalssilog.common.exception.ErrorCode; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; + +@Getter +@RequiredArgsConstructor +public enum AuthErrorCode implements ErrorCode { + + AUTH_ACCESS_TOKEN_EXPIRED("AUTH_ACCESS_TOKEN_EXPIRED", "액세스 토큰이 만료되었습니다.", HttpStatus.UNAUTHORIZED), + AUTH_ACCESS_TOKEN_INVALID("AUTH_ACCESS_TOKEN_INVALID", "유효하지 않은 액세스 토큰입니다.", 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), + OAUTH_CANCELLED("OAUTH_CANCELLED", "소셜 로그인이 취소되었습니다.", HttpStatus.UNAUTHORIZED), + OAUTH_EMAIL_REQUIRED("OAUTH_EMAIL_REQUIRED", "소셜 계정 이메일 제공 동의가 필요합니다.", + HttpStatus.BAD_REQUEST), + AUTH_MOBILE_CODE_INVALID("AUTH_MOBILE_CODE_INVALID", "모바일 인증 코드가 만료되었거나 유효하지 않습니다.", + HttpStatus.BAD_REQUEST), + AUTH_PKCE_VERIFICATION_FAILED("AUTH_PKCE_VERIFICATION_FAILED", "PKCE 검증에 실패했습니다.", + HttpStatus.BAD_REQUEST), + AUTH_REDIRECT_URI_INVALID("AUTH_REDIRECT_URI_INVALID", "허용되지 않은 모바일 리다이렉트 URI입니다.", + HttpStatus.BAD_REQUEST), + AUTH_MOBILE_TRANSACTION_EXPIRED("AUTH_MOBILE_TRANSACTION_EXPIRED", "모바일 로그인 요청이 만료되었습니다.", + HttpStatus.BAD_REQUEST), + AUTH_FLOW_IN_PROGRESS("AUTH_FLOW_IN_PROGRESS", "인증 요청을 처리 중입니다.", HttpStatus.CONFLICT), + AUTH_TICKET_CHANNEL_MISMATCH("AUTH_TICKET_CHANNEL_MISMATCH", "이 인증 티켓은 현재 요청에서 사용할 수 없습니다.", + HttpStatus.BAD_REQUEST), + GUEST_CREDENTIAL_INVALID("GUEST_CREDENTIAL_INVALID", "유효하지 않은 게스트 인증 정보입니다.", + HttpStatus.UNAUTHORIZED), + GUEST_CREDENTIAL_EXPIRED("GUEST_CREDENTIAL_EXPIRED", "게스트 인증 정보가 만료되었습니다.", + HttpStatus.UNAUTHORIZED), + GUEST_ISSUANCE_RATE_LIMITED("GUEST_ISSUANCE_RATE_LIMITED", "게스트 인증 정보 발급 요청이 너무 많습니다.", + HttpStatus.TOO_MANY_REQUESTS), + GUEST_ISSUANCE_UNAVAILABLE("GUEST_ISSUANCE_UNAVAILABLE", "게스트 인증 정보를 발급할 수 없습니다.", + HttpStatus.SERVICE_UNAVAILABLE), + SESSION_NOT_FOUND("SESSION_NOT_FOUND", "세션을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + ALREADY_LINKED_PROVIDER("ACCOUNT_ALREADY_LINKED", "이미 연결된 소셜 로그인입니다.", HttpStatus.CONFLICT); + + private final String code; + private final String message; + private final HttpStatus status; +} diff --git a/auth/src/main/java/com/nalssilog/auth/application/AuthService.java b/auth/src/main/java/com/nalssilog/auth/core/AuthService.java similarity index 57% rename from auth/src/main/java/com/nalssilog/auth/application/AuthService.java rename to auth/src/main/java/com/nalssilog/auth/core/AuthService.java index 6ed0ea3..202f485 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/AuthService.java +++ b/auth/src/main/java/com/nalssilog/auth/core/AuthService.java @@ -1,15 +1,20 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.core; -import com.nalssilog.auth.application.dto.DeviceInfo; -import com.nalssilog.auth.application.dto.SessionView; -import com.nalssilog.auth.client.MemberClient; -import com.nalssilog.auth.client.OAuthUserInfo; import com.nalssilog.auth.config.AuthProperties; -import com.nalssilog.auth.domain.AuthErrorCode; -import com.nalssilog.auth.domain.LinkTicket; -import com.nalssilog.auth.domain.RefreshRejectedException; -import com.nalssilog.auth.domain.SignupTicket; -import com.nalssilog.auth.repository.AuthTicketStore; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.oauth.OAuthUserInfo; +import com.nalssilog.auth.ticket.AuthChannel; +import com.nalssilog.auth.ticket.AuthTicketStore.SignupClaim; +import com.nalssilog.auth.ticket.AuthTicketStore.SignupClaimStatus; +import com.nalssilog.auth.ticket.AuthTicketStore.SignupCompletion; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.ticket.LinkTicket; +import com.nalssilog.auth.ticket.SignupTicket; +import com.nalssilog.auth.token.AuthSessionService; +import com.nalssilog.auth.token.AuthTokenService; +import com.nalssilog.auth.token.SessionView; +import com.nalssilog.auth.token.TokenPair; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.application.dto.TermsAgreement; @@ -19,6 +24,7 @@ import java.util.Optional; import java.util.UUID; import lombok.RequiredArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; /** @@ -42,18 +48,23 @@ public MeState me( boolean hasAuthenticationCookie ) { if (memberId != null) { + return MeState.authenticated(memberClient.getMemberInfo(memberId)); } Optional signup = signupTicketId.flatMap(ticketStore::findSignup); + if (signup.isPresent()) { SignupTicket ticket = signup.get(); + return MeState.signupRequired(ticket.provider(), ticket.email()); } Optional link = linkTicketId.flatMap(ticketStore::findLink); + if (link.isPresent()) { LinkTicket ticket = link.get(); + return MeState.linkRequired(ticket.provider(), ticket.email(), ticket.existingProviders()); } @@ -65,19 +76,92 @@ public MeState me( } public SignupResult signup(String ticketId, List agreedTerms, DeviceInfo device) { - SignupTicket ticket = findSignupTicket(ticketId); - MemberInfo member = memberClient.registerMember( - new OAuthUserInfo(ticket.provider(), ticket.providerUserId(), ticket.email(), ticket.socialName()), - agreedTerms); - TokenPair tokens = authTokenService.issue( - member.id(), member.status(), ticket.provider(), device); - ticketStore.deleteSignup(ticketId); + return signup(ticketId, agreedTerms, device, AuthChannel.WEB); + } - return new SignupResult(member, tokens); + public SignupResult signupMobile( + String ticketId, + List agreedTerms, + DeviceInfo device + ) { + + return signup(ticketId, agreedTerms, device, AuthChannel.MOBILE); + } + + private SignupResult signup( + String ticketId, + List agreedTerms, + DeviceInfo device, + AuthChannel channel + ) { + if (ticketId == null || ticketId.isBlank()) { + throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); + } + + String claimId = UUID.randomUUID().toString(); + SignupClaim claim = ticketStore.claimSignup( + ticketId, + claimId, + properties.ticket().ttl()); + + if (claim.status() == SignupClaimStatus.COMPLETED) { + + return replayCompletedSignup(claim.completion()); + } + if (claim.status() == SignupClaimStatus.IN_PROGRESS) { + throw new NalssiLogException(AuthErrorCode.AUTH_FLOW_IN_PROGRESS); + } + if (claim.status() == SignupClaimStatus.MISSING) { + throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); + } + + SignupTicket ticket = claim.ticket(); + + if (ticket.effectiveChannel() != channel) { + ticketStore.releaseSignupClaim(ticketId, claimId); + throw new NalssiLogException(AuthErrorCode.AUTH_TICKET_CHANNEL_MISMATCH); + } + + try { + MemberInfo member = registerOrRecover(ticket, agreedTerms); + TokenPair tokens = authTokenService.issue( + member.id(), member.status(), ticket.provider(), device); + + ticketStore.completeSignup( + ticketId, + claimId, + new SignupCompletion( + member.id(), + ticket.provider(), + tokens.accessToken(), + tokens.refreshToken(), + tokens.refreshTokenMaxAge().toMillis(), + java.time.Instant.now().toEpochMilli()), + properties.refresh().retryGrace()); + + return new SignupResult(member, tokens); + } catch (RuntimeException exception) { + ticketStore.releaseSignupClaim(ticketId, claimId); + throw exception; + } } public TokenPair refresh(String refreshToken, DeviceInfo device) { + + return refresh(refreshToken, device, true); + } + + public TokenPair refreshMobile(String refreshToken, DeviceInfo device) { + + return refresh(refreshToken, device, false); + } + + private TokenPair refresh( + String refreshToken, + DeviceInfo device, + boolean clearWebCookiesOnFailure + ) { try { if (refreshToken == null || refreshToken.isBlank()) { throw new NalssiLogException(AuthErrorCode.AUTH_SESSION_EXPIRED); @@ -85,7 +169,8 @@ public TokenPair refresh(String refreshToken, DeviceInfo device) { return authTokenService.refresh(refreshToken, device); } catch (NalssiLogException exception) { - if (isTerminalRefreshError(exception)) { + if (clearWebCookiesOnFailure + && isTerminalRefreshError(exception)) { throw new RefreshRejectedException(exception); } @@ -102,16 +187,18 @@ public void withdraw(Long memberId) { authTokenService.revokeAllSessions(memberId); } - public List sessions(Long memberId, Optional refreshToken) { - return authSessionService.listSessions(memberId, currentTokenHash(refreshToken)); + public List sessions(Long memberId, String currentSessionId) { + + return authSessionService.listSessions(memberId, currentSessionId); } public boolean revokeSession( Long memberId, String sessionId, - Optional refreshToken + String currentSessionId ) { - return authSessionService.revokeSession(memberId, sessionId, currentTokenHash(refreshToken)); + + return authSessionService.revokeSession(memberId, sessionId, currentSessionId); } public SocialLinkStart startSocialLink(Long memberId, String provider) { @@ -123,6 +210,7 @@ public SocialLinkStart startSocialLink(Long memberId, String provider) { } String intentId = UUID.randomUUID().toString(); + ticketStore.saveLinkIntent(intentId, memberId, properties.ticket().ttl()); return new SocialLinkStart(intentId, loginUrl(target)); @@ -130,6 +218,7 @@ public SocialLinkStart startSocialLink(Long memberId, String provider) { public String consentLink(String ticketId) { LinkTicket ticket = findLinkTicket(ticketId); + ticketStore.markLinkConsented(ticketId, properties.ticket().ttl()); MemberInfo target = memberClient.getMemberInfo(ticket.targetMemberId()); @@ -148,6 +237,7 @@ public void cancelLink(Optional ticketId) { } public String oauthAuthorizationUrl(String provider) { + return "/oauth2/authorization/" + provider(provider).name().toLowerCase(Locale.ROOT); } @@ -160,6 +250,49 @@ private SignupTicket findSignupTicket(String ticketId) { .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); } + private MemberInfo registerOrRecover( + SignupTicket ticket, + List agreedTerms + ) { + try { + + return memberClient.registerMember( + new OAuthUserInfo( + ticket.provider(), + ticket.providerUserId(), + ticket.email(), + ticket.socialName()), + agreedTerms); + } catch (DataIntegrityViolationException exception) { + + return memberClient.findMemberInfo( + ticket.provider(), + ticket.providerUserId()) + .orElseThrow(() -> exception); + } + } + + private SignupResult replayCompletedSignup(SignupCompletion completion) { + MemberInfo member = memberClient.getMemberInfo(completion.memberId()); + long elapsedMillis = Math.max( + 0, + java.time.Instant.now().toEpochMilli() + - completion.completedAtEpochMillis()); + long remainingMillis = + completion.refreshTokenMaxAgeMillis() - elapsedMillis; + + if (remainingMillis < 1_000) { + throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); + } + + TokenPair tokens = new TokenPair( + completion.accessToken(), + completion.refreshToken(), + java.time.Duration.ofMillis(remainingMillis)); + + return new SignupResult(member, tokens); + } + private LinkTicket findLinkTicket(String ticketId) { if (ticketId == null || ticketId.isBlank()) { throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); @@ -169,17 +302,15 @@ private LinkTicket findLinkTicket(String ticketId) { .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); } - private String currentTokenHash(Optional refreshToken) { - return refreshToken.map(authTokenService::tokenHash).orElse(null); - } - private boolean isTerminalRefreshError(NalssiLogException exception) { + return exception.getErrorCode() == AuthErrorCode.AUTH_SESSION_EXPIRED || exception.getErrorCode() == AuthErrorCode.AUTH_REFRESH_REUSED; } private Provider provider(String provider) { try { + return Provider.from(provider); } catch (IllegalArgumentException _) { throw new NalssiLogException(AuthErrorCode.UNSUPPORTED_PROVIDER); @@ -187,10 +318,12 @@ private Provider provider(String provider) { } private String loginUrl(Provider provider) { + return "/api/auth/login/" + provider.name().toLowerCase(Locale.ROOT); } private String reauthenticationUrl(Provider provider) { + return "/api/auth/link/reauth/" + provider.name().toLowerCase(Locale.ROOT); } @@ -210,10 +343,12 @@ public record MeState( ) { public static MeState authenticated(MemberInfo member) { + return new MeState(MeStatus.AUTHENTICATED, member, null, null, List.of()); } public static MeState signupRequired(Provider provider, String email) { + return new MeState(MeStatus.SIGNUP_REQUIRED, null, provider, email, List.of()); } @@ -222,10 +357,12 @@ public static MeState linkRequired( String email, List existingProviders ) { + return new MeState(MeStatus.LINK_REQUIRED, null, provider, email, List.copyOf(existingProviders)); } public static MeState none() { + return new MeState(MeStatus.NONE, null, null, null, List.of()); } } diff --git a/auth/src/main/java/com/nalssilog/auth/domain/RefreshRejectedException.java b/auth/src/main/java/com/nalssilog/auth/core/RefreshRejectedException.java similarity index 92% rename from auth/src/main/java/com/nalssilog/auth/domain/RefreshRejectedException.java rename to auth/src/main/java/com/nalssilog/auth/core/RefreshRejectedException.java index c31ef7a..36d4941 100644 --- a/auth/src/main/java/com/nalssilog/auth/domain/RefreshRejectedException.java +++ b/auth/src/main/java/com/nalssilog/auth/core/RefreshRejectedException.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.domain; +package com.nalssilog.auth.core; import com.nalssilog.common.exception.NalssiLogException; diff --git a/auth/src/main/java/com/nalssilog/auth/application/dto/DeviceInfo.java b/auth/src/main/java/com/nalssilog/auth/device/DeviceInfo.java similarity index 86% rename from auth/src/main/java/com/nalssilog/auth/application/dto/DeviceInfo.java rename to auth/src/main/java/com/nalssilog/auth/device/DeviceInfo.java index 7cbab65..f5e765e 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/dto/DeviceInfo.java +++ b/auth/src/main/java/com/nalssilog/auth/device/DeviceInfo.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application.dto; +package com.nalssilog.auth.device; /** * 세션이 생성된 기기 정보. User-Agent 에서 파생한 표시용 이름 + 클라이언트 IP. diff --git a/auth/src/main/java/com/nalssilog/auth/config/DeviceInfoResolver.java b/auth/src/main/java/com/nalssilog/auth/device/DeviceInfoResolver.java similarity index 80% rename from auth/src/main/java/com/nalssilog/auth/config/DeviceInfoResolver.java rename to auth/src/main/java/com/nalssilog/auth/device/DeviceInfoResolver.java index 07894d6..47d3ff9 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/DeviceInfoResolver.java +++ b/auth/src/main/java/com/nalssilog/auth/device/DeviceInfoResolver.java @@ -1,6 +1,7 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.device; -import com.nalssilog.auth.application.dto.DeviceInfo; +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.common.web.TrustedProxyChain; import jakarta.servlet.http.HttpServletRequest; import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; @@ -15,14 +16,32 @@ public class DeviceInfoResolver { private static final int MAX_IP_LENGTH = 45; // IPv6 최대 길이 private static final int MAX_NAME_LENGTH = 60; + private final TrustedProxyChain trustedProxyChain; + + public DeviceInfoResolver(AuthProperties properties) { + this.trustedProxyChain = new TrustedProxyChain(properties.mobile().trustedProxies()); + } + public DeviceInfo resolve(HttpServletRequest request) { String userAgent = request.getHeader("User-Agent"); - return new DeviceInfo(deviceName(userAgent), clientIp(request)); + return new DeviceInfo(deviceName(userAgent), resolveIp(request)); + } + + public String resolveIp(HttpServletRequest request) { + String ip = trustedProxyChain.resolve(request); + + if (!StringUtils.hasText(ip)) { + + return ""; + } + + return ip.length() > MAX_IP_LENGTH ? ip.substring(0, MAX_IP_LENGTH) : ip; } private String deviceName(String userAgent) { if (!StringUtils.hasText(userAgent)) { + return "알 수 없는 기기"; } @@ -35,21 +54,27 @@ private String deviceName(String userAgent) { private String browser(String ua) { if (ua.contains("Edg")) { + return "Edge"; } if (ua.contains("SamsungBrowser")) { + return "Samsung Internet"; } if (ua.contains("OPR") || ua.contains("Opera")) { + return "Opera"; } if (ua.contains("Firefox")) { + return "Firefox"; } if (ua.contains("Chrome")) { + return "Chrome"; } if (ua.contains("Safari")) { + return "Safari"; } @@ -58,37 +83,31 @@ private String browser(String ua) { private String os(String ua) { if (ua.contains("iPhone")) { + return "iPhone"; } if (ua.contains("iPad")) { + return "iPad"; } if (ua.contains("Android")) { + return "Android"; } if (ua.contains("Windows")) { + return "Windows"; } if (ua.contains("Mac OS X") || ua.contains("Macintosh")) { + return "Mac"; } if (ua.contains("Linux")) { + return "Linux"; } return "기타"; } - private String clientIp(HttpServletRequest request) { - String forwarded = request.getHeader("X-Forwarded-For"); - String ip = StringUtils.hasText(forwarded) - ? forwarded.split(",")[0].strip() - : request.getRemoteAddr(); - - if (!StringUtils.hasText(ip)) { - return ""; - } - - return ip.length() > MAX_IP_LENGTH ? ip.substring(0, MAX_IP_LENGTH) : ip; - } } diff --git a/auth/src/main/java/com/nalssilog/auth/domain/AuthErrorCode.java b/auth/src/main/java/com/nalssilog/auth/domain/AuthErrorCode.java deleted file mode 100644 index ce43197..0000000 --- a/auth/src/main/java/com/nalssilog/auth/domain/AuthErrorCode.java +++ /dev/null @@ -1,26 +0,0 @@ -package com.nalssilog.auth.domain; - -import com.nalssilog.common.exception.ErrorCode; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; - -@Getter -@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), - OAUTH_CANCELLED("OAUTH_CANCELLED", "소셜 로그인이 취소되었습니다.", HttpStatus.UNAUTHORIZED), - SESSION_NOT_FOUND("SESSION_NOT_FOUND", "세션을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), - ALREADY_LINKED_PROVIDER("ACCOUNT_ALREADY_LINKED", "이미 연동된 소셜 로그인입니다.", HttpStatus.CONFLICT); - - private final String code; - private final String message; - private final HttpStatus status; -} diff --git a/auth/src/main/java/com/nalssilog/auth/domain/SignupTicket.java b/auth/src/main/java/com/nalssilog/auth/domain/SignupTicket.java deleted file mode 100644 index 146bec9..0000000 --- a/auth/src/main/java/com/nalssilog/auth/domain/SignupTicket.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.nalssilog.auth.domain; - -import com.fasterxml.jackson.annotation.JsonAlias; -import com.nalssilog.member.domain.Provider; - -/** - * 소셜 인증은 끝났지만 아직 가입을 확정하지 않은 신규 사용자의 임시 상태. (Redis 단기 보관) - */ -public record SignupTicket( - Provider provider, - String providerUserId, - String email, - @JsonAlias("nickname") String socialName -) { -} diff --git a/auth/src/main/java/com/nalssilog/auth/client/MemberClient.java b/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java similarity index 84% rename from auth/src/main/java/com/nalssilog/auth/client/MemberClient.java rename to auth/src/main/java/com/nalssilog/auth/member/MemberClient.java index 6c6b40f..6a23afd 100644 --- a/auth/src/main/java/com/nalssilog/auth/client/MemberClient.java +++ b/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java @@ -1,10 +1,12 @@ -package com.nalssilog.auth.client; +package com.nalssilog.auth.member; +import com.nalssilog.auth.oauth.OAuthUserInfo; import com.nalssilog.member.application.MemberAccountService; import com.nalssilog.member.application.MemberRegistrationService; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.application.dto.SocialLoginResult; import com.nalssilog.member.application.dto.TermsAgreement; +import com.nalssilog.member.domain.Provider; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -21,28 +23,41 @@ public class MemberClient { private final MemberRegistrationService memberRegistrationService; public SocialLoginResult resolveSocialLogin(OAuthUserInfo userInfo) { + return memberAccountService.resolveSocialLogin( userInfo.provider(), userInfo.providerUserId(), userInfo.email()); } public MemberInfo registerMember(OAuthUserInfo userInfo, List agreedTerms) { + return memberRegistrationService.registerMember( userInfo.provider(), userInfo.providerUserId(), userInfo.email(), userInfo.socialName(), agreedTerms); } public MemberInfo linkSocial(Long targetMemberId, OAuthUserInfo userInfo) { + return memberAccountService.linkSocial( targetMemberId, userInfo.provider(), userInfo.providerUserId(), userInfo.email()); } public MemberInfo getMemberInfo(Long memberId) { + return memberAccountService.getMemberInfo(memberId); } public Optional findMemberInfo(Long memberId) { + return memberAccountService.findMemberInfo(memberId); } + public Optional findMemberInfo( + Provider provider, + String providerUserId + ) { + + return memberAccountService.findMemberInfo(provider, providerUserId); + } + public void withdraw(Long memberId) { memberAccountService.withdraw(memberId); } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthController.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthController.java new file mode 100644 index 0000000..babf529 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthController.java @@ -0,0 +1,132 @@ +package com.nalssilog.auth.mobile; + +import com.nalssilog.auth.config.AuthProperties; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequestMapping("/api/auth/mobile") +@RequiredArgsConstructor +public class MobileAuthController { + + private final MobileAuthService mobileAuthService; + private final MobileDeviceInfoResolver deviceInfoResolver; + private final AuthProperties properties; + + @GetMapping("/login/{provider}") + public void login( + @PathVariable String provider, + @RequestParam(name = "redirect_uri") @NotBlank @Size(max = 500) String redirectUri, + @RequestParam(name = "code_challenge") @NotBlank @Size(min = 43, max = 43) String codeChallenge, + @RequestParam(name = "code_challenge_method") @NotBlank String codeChallengeMethod, + @RequestParam @NotBlank @Size(min = 16, max = 256) String state, + HttpServletResponse response + ) throws IOException { + response.sendRedirect(mobileAuthService.startLogin( + provider, + redirectUri, + codeChallenge, + codeChallengeMethod, + state)); + } + + @PostMapping("/token") + public MobileTokenResponse token( + @Valid @RequestBody MobileTokenRequest request, + HttpServletRequest httpRequest + ) { + + return MobileTokenResponse.from( + mobileAuthService.exchange( + request.code(), + request.codeVerifier(), + request.redirectUri(), + deviceInfoResolver.resolve(request.device(), httpRequest)), + properties); + } + + @PostMapping("/refresh") + public MobileRefreshResponse refresh( + @Valid @RequestBody MobileRefreshRequest request, + HttpServletRequest httpRequest + ) { + + return MobileRefreshResponse.from( + mobileAuthService.refresh( + request.refreshToken(), + deviceInfoResolver.resolve(request.device(), httpRequest)), + properties); + } + + @PostMapping("/logout") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void logout(@Valid @RequestBody MobileLogoutRequest request) { + mobileAuthService.logout(request.refreshToken()); + } + + @PostMapping("/signup") + public MobileTokenResponse signup( + @Valid @RequestBody MobileSignupRequest request, + HttpServletRequest httpRequest + ) { + + return MobileTokenResponse.signup( + mobileAuthService.signup( + request.signupTicket(), + request.agreedTerms(), + deviceInfoResolver.resolve(request.device(), httpRequest)), + properties); + } + + @PostMapping("/link/consent") + public MobileAuthorizationResponse consentLink( + @Valid @RequestBody MobileLinkConsentRequest request + ) { + + return new MobileAuthorizationResponse(mobileAuthService.consentLink( + request.linkTicket(), + request.redirectUri(), + request.codeChallenge(), + request.codeChallengeMethod(), + request.state())); + } + + @PostMapping("/link/cancel") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void cancelLink(@Valid @RequestBody MobileLinkCancelRequest request) { + mobileAuthService.cancelLink(request.linkTicket()); + } + + @PostMapping("/link/social/{provider}") + public MobileAuthorizationResponse startSettingsLink( + @AuthenticationPrincipal Long memberId, + @PathVariable String provider, + @Valid @RequestBody MobileLinkStartRequest request + ) { + + return new MobileAuthorizationResponse(mobileAuthService.startSettingsLink( + memberId, + provider, + request.redirectUri(), + request.codeChallenge(), + request.codeChallengeMethod(), + request.state())); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthService.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthService.java new file mode 100644 index 0000000..a6ab04c --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthService.java @@ -0,0 +1,104 @@ +package com.nalssilog.auth.mobile; + +import com.nalssilog.auth.core.AuthService.SignupResult; +import com.nalssilog.auth.core.AuthService; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.mobile.oauth.MobileOAuthService.ExchangeResult; +import com.nalssilog.auth.mobile.oauth.MobileOAuthService; +import com.nalssilog.auth.token.TokenPair; +import com.nalssilog.member.application.dto.TermsAgreement; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class MobileAuthService { + + private final MobileOAuthService mobileOAuthService; + private final AuthService authService; + + public String startLogin( + String provider, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String state + ) { + + return mobileOAuthService.startLogin( + provider, + redirectUri, + codeChallenge, + codeChallengeMethod, + state); + } + + public ExchangeResult exchange( + String code, + String verifier, + String redirectUri, + DeviceInfo device + ) { + + return mobileOAuthService.exchange(code, verifier, redirectUri, device); + } + + public TokenPair refresh(String refreshToken, DeviceInfo device) { + + return authService.refreshMobile(refreshToken, device); + } + + public void logout(String refreshToken) { + authService.logout(Optional.ofNullable(refreshToken)); + } + + public SignupResult signup( + String ticketId, + List agreedTerms, + DeviceInfo device + ) { + mobileOAuthService.mobileSignupTicket(ticketId); + + return authService.signupMobile(ticketId, agreedTerms, device); + } + + public String consentLink( + String linkTicket, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String state + ) { + + return mobileOAuthService.startLinkReauthentication( + linkTicket, + redirectUri, + codeChallenge, + codeChallengeMethod, + state); + } + + public void cancelLink(String linkTicket) { + mobileOAuthService.cancelLink(linkTicket); + } + + public String startSettingsLink( + Long memberId, + String provider, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String state + ) { + + return mobileOAuthService.startSettingsLink( + memberId, + provider, + redirectUri, + codeChallenge, + codeChallengeMethod, + state); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthorizationResponse.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthorizationResponse.java new file mode 100644 index 0000000..4e1408d --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthorizationResponse.java @@ -0,0 +1,4 @@ +package com.nalssilog.auth.mobile; + +public record MobileAuthorizationResponse(String authorizationUrl) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceInfoResolver.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceInfoResolver.java new file mode 100644 index 0000000..7d6eb03 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceInfoResolver.java @@ -0,0 +1,31 @@ +package com.nalssilog.auth.mobile; + +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.device.DeviceInfoResolver; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class MobileDeviceInfoResolver { + + private static final int MAX_NAME_LENGTH = 60; + + private final DeviceInfoResolver deviceInfoResolver; + + public DeviceInfo resolve( + MobileDeviceRequest device, + HttpServletRequest request + ) { + String label = device.platform().name() + + " · " + device.deviceName().strip() + + " · " + device.appVersion().strip(); + + if (label.length() > MAX_NAME_LENGTH) { + label = label.substring(0, MAX_NAME_LENGTH); + } + + return new DeviceInfo(label, deviceInfoResolver.resolveIp(request)); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceRequest.java new file mode 100644 index 0000000..5452867 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileDeviceRequest.java @@ -0,0 +1,19 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record MobileDeviceRequest( + @NotNull MobilePlatform platform, + @NotBlank + @Size(max = 60) + @Pattern(regexp = "^[^\\p{Cntrl}]+$") + String deviceName, + @NotBlank + @Size(max = 30) + @Pattern(regexp = "^[0-9A-Za-z][0-9A-Za-z._+() -]*$") + String appVersion +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkCancelRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkCancelRequest.java new file mode 100644 index 0000000..3c158d4 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkCancelRequest.java @@ -0,0 +1,9 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record MobileLinkCancelRequest( + @NotBlank @Size(max = 200) String linkTicket +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkConsentRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkConsentRequest.java new file mode 100644 index 0000000..c1d24f6 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkConsentRequest.java @@ -0,0 +1,13 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record MobileLinkConsentRequest( + @NotBlank @Size(max = 200) String linkTicket, + @NotBlank @Size(max = 500) String redirectUri, + @NotBlank @Size(min = 43, max = 43) String codeChallenge, + @NotBlank String codeChallengeMethod, + @NotBlank @Size(min = 16, max = 256) String state +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkStartRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkStartRequest.java new file mode 100644 index 0000000..09cb08a --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLinkStartRequest.java @@ -0,0 +1,12 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record MobileLinkStartRequest( + @NotBlank @Size(max = 500) String redirectUri, + @NotBlank @Size(min = 43, max = 43) String codeChallenge, + @NotBlank String codeChallengeMethod, + @NotBlank @Size(min = 16, max = 256) String state +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileLogoutRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLogoutRequest.java new file mode 100644 index 0000000..a041adc --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileLogoutRequest.java @@ -0,0 +1,9 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record MobileLogoutRequest( + @NotBlank @Size(max = 500) String refreshToken +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobilePlatform.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobilePlatform.java new file mode 100644 index 0000000..afa6d71 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobilePlatform.java @@ -0,0 +1,6 @@ +package com.nalssilog.auth.mobile; + +public enum MobilePlatform { + ANDROID, + IOS +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshRequest.java new file mode 100644 index 0000000..e805ddc --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshRequest.java @@ -0,0 +1,12 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record MobileRefreshRequest( + @NotBlank @Size(max = 500) String refreshToken, + @Valid @NotNull MobileDeviceRequest device +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshResponse.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshResponse.java new file mode 100644 index 0000000..39bfdb0 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshResponse.java @@ -0,0 +1,26 @@ +package com.nalssilog.auth.mobile; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.token.TokenPair; + +public record MobileRefreshResponse( + String tokenType, + String accessToken, + long accessTokenExpiresIn, + String refreshToken, + long refreshTokenExpiresIn +) { + + public static MobileRefreshResponse from( + TokenPair tokens, + AuthProperties properties + ) { + + return new MobileRefreshResponse( + "Bearer", + tokens.accessToken(), + properties.jwt().accessTokenTtl().toSeconds(), + tokens.refreshToken(), + tokens.refreshTokenMaxAge().toSeconds()); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileSignupRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileSignupRequest.java new file mode 100644 index 0000000..60b6c2e --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileSignupRequest.java @@ -0,0 +1,15 @@ +package com.nalssilog.auth.mobile; + +import com.nalssilog.member.application.dto.TermsAgreement; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; + +public record MobileSignupRequest( + @NotBlank @Size(max = 200) String signupTicket, + @NotNull List<@Valid @NotNull TermsAgreement> agreedTerms, + @Valid @NotNull MobileDeviceRequest device +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenRequest.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenRequest.java new file mode 100644 index 0000000..e4fc9e5 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenRequest.java @@ -0,0 +1,14 @@ +package com.nalssilog.auth.mobile; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record MobileTokenRequest( + @NotBlank @Size(max = 200) String code, + @NotBlank @Size(min = 43, max = 128) String codeVerifier, + @NotBlank @Size(max = 500) String redirectUri, + @Valid @NotNull MobileDeviceRequest device +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java new file mode 100644 index 0000000..f19f40f --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java @@ -0,0 +1,82 @@ +package com.nalssilog.auth.mobile; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthService.SignupResult; +import com.nalssilog.auth.mobile.oauth.MobileAuthResult; +import com.nalssilog.auth.mobile.oauth.MobileOAuthService.ExchangeResult; +import com.nalssilog.auth.token.TokenPair; +import com.nalssilog.auth.web.MeResponse.Avatar; +import com.nalssilog.auth.web.MeResponse.PendingAuth; +import com.nalssilog.auth.web.MeResponse.User; +import com.nalssilog.member.application.dto.MemberInfo; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record MobileTokenResponse( + MobileAuthResult result, + String tokenType, + String accessToken, + Long accessTokenExpiresIn, + String refreshToken, + Long refreshTokenExpiresIn, + User user, + String signupTicket, + String linkTicket, + PendingAuth pendingAuth +) { + + public static MobileTokenResponse from( + ExchangeResult result, + AuthProperties properties + ) { + TokenPair tokens = result.tokens(); + + return new MobileTokenResponse( + result.result(), + tokens == null ? null : "Bearer", + tokens == null ? null : tokens.accessToken(), + tokens == null ? null : properties.jwt().accessTokenTtl().toSeconds(), + tokens == null ? null : tokens.refreshToken(), + tokens == null ? null : tokens.refreshTokenMaxAge().toSeconds(), + user(result.member()), + result.signupTicket(), + result.linkTicket(), + result.pendingProvider() == null + ? null + : new PendingAuth( + result.pendingProvider(), + result.pendingEmail(), + result.existingProviders())); + } + + public static MobileTokenResponse signup( + SignupResult result, + AuthProperties properties + ) { + TokenPair tokens = result.tokens(); + + return new MobileTokenResponse( + MobileAuthResult.SUCCESS, + "Bearer", + tokens.accessToken(), + properties.jwt().accessTokenTtl().toSeconds(), + tokens.refreshToken(), + tokens.refreshTokenMaxAge().toSeconds(), + user(result.member()), + null, + null, + null); + } + + private static User user(MemberInfo member) { + if (member == null) { + + return null; + } + + return new User( + String.valueOf(member.id()), + member.nickname(), + new Avatar(member.avatarType(), member.avatarValue())); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/GuestCredentialResponse.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/GuestCredentialResponse.java new file mode 100644 index 0000000..1739dd1 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/GuestCredentialResponse.java @@ -0,0 +1,13 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.auth.mobile.guest.MobileGuestCredentialService.IssuedGuestCredential; + +public record GuestCredentialResponse(String guestToken, long expiresIn) { + + public static GuestCredentialResponse from(IssuedGuestCredential credential) { + + return new GuestCredentialResponse( + credential.token(), + credential.expiresIn().toSeconds()); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestController.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestController.java new file mode 100644 index 0000000..df0592b --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestController.java @@ -0,0 +1,27 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.auth.device.DeviceInfoResolver; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/mobile/guests") +@RequiredArgsConstructor +public class MobileGuestController { + + private final MobileGuestCredentialService credentialService; + private final DeviceInfoResolver deviceInfoResolver; + + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public GuestCredentialResponse issue(HttpServletRequest request) { + + return GuestCredentialResponse.from( + credentialService.issue(deviceInfoResolver.resolveIp(request))); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredential.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredential.java new file mode 100644 index 0000000..0544da4 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredential.java @@ -0,0 +1,70 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.common.domain.BaseTimeEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "mobile_guest_credential", uniqueConstraints = { + @UniqueConstraint(name = "uk_mobile_guest_token_hash", columnNames = "token_hash"), + @UniqueConstraint(name = "uk_mobile_guest_anonymous_key", columnNames = "anonymous_key") +}) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class MobileGuestCredential extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "token_hash", nullable = false, length = 64, updatable = false) + private String tokenHash; + + @Column(name = "anonymous_key", nullable = false, length = 36, updatable = false) + private String anonymousKey; + + @Column(name = "expires_at", nullable = false, updatable = false) + private Instant expiresAt; + + @Column(name = "revoked_at") + private Instant revokedAt; + + public static MobileGuestCredential issue( + String tokenHash, + String anonymousKey, + Instant expiresAt + ) { + MobileGuestCredential credential = new MobileGuestCredential(); + + credential.tokenHash = tokenHash; + credential.anonymousKey = anonymousKey; + credential.expiresAt = expiresAt; + + return credential; + } + + public boolean isExpired(Instant now) { + + return !expiresAt.isAfter(now); + } + + public boolean isRevoked() { + + return revokedAt != null; + } + + public void revoke() { + if (revokedAt == null) { + revokedAt = Instant.now(); + } + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupService.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupService.java new file mode 100644 index 0000000..026c444 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupService.java @@ -0,0 +1,37 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.auth.config.AuthProperties; +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MobileGuestCredentialCleanupService { + + private final MobileGuestCredentialRepository repository; + private final AuthProperties properties; + + @Scheduled( + fixedDelayString = "${nalssilog.auth.guest.cleanup-interval:6h}", + initialDelayString = "${nalssilog.auth.guest.cleanup-initial-delay:1m}") + @Transactional + public void cleanupExpiredCredentials() { + cleanupExpiredCredentialsAt(Instant.now()); + } + + int cleanupExpiredCredentialsAt(Instant now) { + Instant cutoff = now.minus(properties.guest().expiredRetention()); + int deleted = repository.deleteByExpiresAtLessThanEqual(cutoff); + + if (deleted > 0) { + log.info("auth.guest.expired_credentials_deleted count={}", deleted); + } + + return deleted; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialFilter.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialFilter.java new file mode 100644 index 0000000..c606f83 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialFilter.java @@ -0,0 +1,73 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.security.ApiAuthenticationEntryPoint; +import com.nalssilog.auth.security.CredentialAuthenticationException; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.security.VerifiedRequestCredentials; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class MobileGuestCredentialFilter extends OncePerRequestFilter { + + private final MobileGuestCredentialService credentialService; + private final ApiAuthenticationEntryPoint authenticationEntryPoint; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + List values = Collections.list( + request.getHeaders(MobileGuestCredentialService.HEADER)); + + if (values.isEmpty()) { + filterChain.doFilter(request, response); + + return; + } + if (values.size() != 1 || values.getFirst() == null + || values.getFirst().isBlank() || values.getFirst().contains(",")) { + reject(request, response, AuthErrorCode.GUEST_CREDENTIAL_INVALID); + + return; + } + + try { + String anonymousKey = credentialService.authenticate(values.getFirst()); + + VerifiedRequestCredentials.markGuest(request, anonymousKey); + filterChain.doFilter(request, response); + } catch (NalssiLogException exception) { + AuthErrorCode errorCode = exception.getErrorCode() instanceof AuthErrorCode authErrorCode + ? authErrorCode + : AuthErrorCode.GUEST_CREDENTIAL_INVALID; + + reject(request, response, errorCode); + } + } + + private void reject( + HttpServletRequest request, + HttpServletResponse response, + AuthErrorCode errorCode + ) throws IOException { + SecurityContextHolder.clearContext(); + authenticationEntryPoint.commence( + request, + response, + new CredentialAuthenticationException(errorCode, false)); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialRepository.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialRepository.java new file mode 100644 index 0000000..fbebf8a --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialRepository.java @@ -0,0 +1,12 @@ +package com.nalssilog.auth.mobile.guest; + +import java.time.Instant; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface MobileGuestCredentialRepository extends JpaRepository { + + Optional findByTokenHash(String tokenHash); + + int deleteByExpiresAtLessThanEqual(Instant cutoff); +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialService.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialService.java new file mode 100644 index 0000000..3b62480 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialService.java @@ -0,0 +1,74 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.security.SecretFingerprint; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MobileGuestCredentialService { + + public static final String HEADER = "X-Nalssilog-Guest-Token"; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int TOKEN_BYTES = 32; + + private final MobileGuestCredentialRepository repository; + private final MobileGuestIssuanceRateLimiter rateLimiter; + private final AuthProperties properties; + + @Transactional + public IssuedGuestCredential issue(String clientIp) { + rateLimiter.check(clientIp); + + String rawToken = randomToken(); + Duration ttl = properties.guest().ttl(); + MobileGuestCredential credential = MobileGuestCredential.issue( + SecretFingerprint.sha256(rawToken), + UUID.randomUUID().toString(), + Instant.now().plus(ttl)); + + repository.save(credential); + + return new IssuedGuestCredential(rawToken, ttl); + } + + public String authenticate(String rawToken) { + if (rawToken == null || rawToken.isBlank() || rawToken.length() > 200) { + throw new NalssiLogException(AuthErrorCode.GUEST_CREDENTIAL_INVALID); + } + + MobileGuestCredential credential = repository.findByTokenHash(SecretFingerprint.sha256(rawToken)) + .orElseThrow(() -> new NalssiLogException(AuthErrorCode.GUEST_CREDENTIAL_INVALID)); + + if (credential.isRevoked()) { + throw new NalssiLogException(AuthErrorCode.GUEST_CREDENTIAL_INVALID); + } + if (credential.isExpired(Instant.now())) { + throw new NalssiLogException(AuthErrorCode.GUEST_CREDENTIAL_EXPIRED); + } + + return credential.getAnonymousKey(); + } + + private String randomToken() { + byte[] bytes = new byte[TOKEN_BYTES]; + + SECURE_RANDOM.nextBytes(bytes); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + public record IssuedGuestCredential(String token, Duration expiresIn) { + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiter.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiter.java new file mode 100644 index 0000000..17e42a0 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiter.java @@ -0,0 +1,78 @@ +package com.nalssilog.auth.mobile.guest; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.security.SecretFingerprint; +import java.util.List; +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; + +@Slf4j +@Component +@RequiredArgsConstructor +public class MobileGuestIssuanceRateLimiter { + + private static final String KEY_PREFIX = "auth:guest:issue:"; + private static final String GLOBAL_KEY = KEY_PREFIX + "global"; + private static final DefaultRedisScript INCREMENT_SCRIPT = new DefaultRedisScript<>(""" + local ipCount = redis.call('INCR', KEYS[1]) + if ipCount == 1 then + redis.call('PEXPIRE', KEYS[1], ARGV[1]) + end + local globalCount = redis.call('INCR', KEYS[2]) + if globalCount == 1 then + redis.call('PEXPIRE', KEYS[2], ARGV[2]) + end + if globalCount > tonumber(ARGV[4]) then + return -globalCount + end + if ipCount > tonumber(ARGV[3]) then + return ipCount + end + return 0 + """, Long.class); + + private final StringRedisTemplate redisTemplate; + private final AuthProperties properties; + + public void check(String clientIp) { + String fingerprint = SecretFingerprint.hmacSha256( + properties.mobile().ipHmacSecret(), + clientIp == null || clientIp.isBlank() ? "unknown" : clientIp.strip()); + Long result; + + try { + result = redisTemplate.execute( + INCREMENT_SCRIPT, + List.of(KEY_PREFIX + "ip:" + fingerprint, GLOBAL_KEY), + String.valueOf(properties.guest().rateLimitWindow().toMillis()), + String.valueOf(properties.guest().globalRateLimitWindow().toMillis()), + String.valueOf(properties.guest().maxIssuances()), + String.valueOf(properties.guest().globalMaxIssuances())); + } catch (DataAccessException exception) { + log.warn("auth.guest.issue_rate_limit_unavailable ip={}", + fingerprint.substring(0, 12)); + throw new NalssiLogException(AuthErrorCode.GUEST_ISSUANCE_UNAVAILABLE); + } + + if (result == null) { + log.warn("auth.guest.issue_rate_limit_unavailable ip={}", + fingerprint.substring(0, 12)); + throw new NalssiLogException(AuthErrorCode.GUEST_ISSUANCE_UNAVAILABLE); + } + if (result > 0) { + log.warn("auth.guest.issue_rate_limited scope=ip ip={} count={}", + fingerprint.substring(0, 12), result); + throw new NalssiLogException(AuthErrorCode.GUEST_ISSUANCE_RATE_LIMITED); + } + if (result < 0) { + log.warn("auth.guest.issue_rate_limited scope=global count={}", -result); + throw new NalssiLogException(AuthErrorCode.GUEST_ISSUANCE_RATE_LIMITED); + } + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileAuthResult.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileAuthResult.java new file mode 100644 index 0000000..1591fb0 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileAuthResult.java @@ -0,0 +1,9 @@ +package com.nalssilog.auth.mobile.oauth; + +public enum MobileAuthResult { + SUCCESS, + SIGNUP_REQUIRED, + LINK_REQUIRED, + LINK_SUCCESS, + FAILED +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java new file mode 100644 index 0000000..5631e93 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java @@ -0,0 +1,51 @@ +package com.nalssilog.auth.mobile.oauth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.oauth2.client.web.AuthorizationRequestRepository; +import org.springframework.security.oauth2.client.web.HttpSessionOAuth2AuthorizationRequestRepository; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.stereotype.Component; + +@Component +public class MobileOAuthAuthorizationRequestRepository + implements AuthorizationRequestRepository { + + private final HttpSessionOAuth2AuthorizationRequestRepository delegate = + new HttpSessionOAuth2AuthorizationRequestRepository(); + + @Override + public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { + + return delegate.loadAuthorizationRequest(request); + } + + @Override + public void saveAuthorizationRequest( + OAuth2AuthorizationRequest authorizationRequest, + HttpServletRequest request, + HttpServletResponse response + ) { + delegate.saveAuthorizationRequest(authorizationRequest, request, response); + } + + @Override + public OAuth2AuthorizationRequest removeAuthorizationRequest( + HttpServletRequest request, + HttpServletResponse response + ) { + OAuth2AuthorizationRequest authorizationRequest = + delegate.removeAuthorizationRequest(request, response); + + if (authorizationRequest != null) { + String transactionId = authorizationRequest.getAttribute( + MobileOAuthRequestAttributes.AUTHORIZATION_ATTRIBUTE); + + if (transactionId != null && !transactionId.isBlank()) { + MobileOAuthRequestAttributes.expose(request, transactionId); + } + } + + return authorizationRequest; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java new file mode 100644 index 0000000..00b5d27 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java @@ -0,0 +1,114 @@ +package com.nalssilog.auth.mobile.oauth; + +import com.nalssilog.auth.oauth.apple.AppleOAuthProperties; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.stereotype.Component; + +@Component +public class MobileOAuthAuthorizationRequestResolver implements OAuth2AuthorizationRequestResolver { + + private final DefaultOAuth2AuthorizationRequestResolver delegate; + private final MobileOAuthTransactionStore transactionStore; + + public MobileOAuthAuthorizationRequestResolver( + ClientRegistrationRepository clientRegistrationRepository, + MobileOAuthTransactionStore transactionStore + ) { + this.delegate = new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository); + this.transactionStore = transactionStore; + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + + return attachMobileTransaction( + request, + customizeProvider(delegate.resolve(request))); + } + + @Override + public OAuth2AuthorizationRequest resolve( + HttpServletRequest request, + String clientRegistrationId + ) { + + return attachMobileTransaction( + request, + customizeProvider( + delegate.resolve(request, clientRegistrationId))); + } + + private OAuth2AuthorizationRequest customizeProvider( + OAuth2AuthorizationRequest authorizationRequest + ) { + if (authorizationRequest == null) { + + return null; + } + + String registrationId = authorizationRequest.getAttribute( + OAuth2ParameterNames.REGISTRATION_ID); + + if (!AppleOAuthProperties.REGISTRATION_ID.equals(registrationId)) { + + return authorizationRequest; + } + + return OAuth2AuthorizationRequest.from(authorizationRequest) + .additionalParameters(parameters -> + parameters.put("response_mode", "form_post")) + .build(); + } + + private OAuth2AuthorizationRequest attachMobileTransaction( + HttpServletRequest request, + OAuth2AuthorizationRequest authorizationRequest + ) { + if (authorizationRequest == null) { + + return null; + } + + String[] values = request.getParameterValues( + MobileOAuthRequestAttributes.TRANSACTION_PARAMETER); + + if (values == null || values.length == 0) { + + return authorizationRequest; + } + if (values.length != 1 || values[0] == null || values[0].isBlank()) { + throw invalidTransaction(); + } + + String transactionId = values[0]; + MobileOAuthTransaction transaction = transactionStore.find(transactionId) + .orElseThrow(this::invalidTransaction); + String registrationId = authorizationRequest.getAttribute( + OAuth2ParameterNames.REGISTRATION_ID); + + if (registrationId == null + || !transaction.provider().name().equalsIgnoreCase(registrationId)) { + throw invalidTransaction(); + } + + return OAuth2AuthorizationRequest.from(authorizationRequest) + .attributes(attributes -> attributes.put( + MobileOAuthRequestAttributes.AUTHORIZATION_ATTRIBUTE, + transactionId)) + .build(); + } + + private OAuth2AuthenticationException invalidTransaction() { + + return new OAuth2AuthenticationException( + new OAuth2Error("invalid_mobile_transaction"), + "Invalid mobile OAuth transaction"); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthCodeStore.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthCodeStore.java new file mode 100644 index 0000000..7b2ef7f --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthCodeStore.java @@ -0,0 +1,147 @@ +package com.nalssilog.auth.mobile.oauth; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.security.SecretFingerprint; +import com.nalssilog.member.domain.Provider; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class MobileOAuthCodeStore { + + private static final String KEY_PREFIX = "auth:mobile:code:"; + private static final String FIELD_RESULT = "result"; + private static final String FIELD_PROVIDER = "provider"; + private static final String FIELD_MEMBER_ID = "memberId"; + private static final String FIELD_TICKET_ID = "ticketId"; + private static final String FIELD_ERROR_CODE = "errorCode"; + private static final String FIELD_ISSUE_TOKENS = "issueTokens"; + private static final String FIELD_REDIRECT_URI = "redirectUri"; + private static final String FIELD_CODE_CHALLENGE = "codeChallenge"; + + @SuppressWarnings("rawtypes") + private static final DefaultRedisScript CONSUME_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('EXISTS', KEYS[1]) == 0 then + return {'MISSING'} + end + if redis.call('HGET', KEYS[1], 'redirectUri') ~= ARGV[1] then + return {'REDIRECT_MISMATCH'} + end + if redis.call('HGET', KEYS[1], 'codeChallenge') ~= ARGV[2] then + return {'PKCE_MISMATCH'} + end + + local result = redis.call('HGET', KEYS[1], 'result') or '' + local provider = redis.call('HGET', KEYS[1], 'provider') or '' + local memberId = redis.call('HGET', KEYS[1], 'memberId') or '' + local ticketId = redis.call('HGET', KEYS[1], 'ticketId') or '' + local errorCode = redis.call('HGET', KEYS[1], 'errorCode') or '' + local issueTokens = redis.call('HGET', KEYS[1], 'issueTokens') or 'false' + redis.call('DEL', KEYS[1]) + return {'OK', result, provider, memberId, ticketId, errorCode, issueTokens} + """, List.class); + + private final StringRedisTemplate redisTemplate; + private final AuthProperties properties; + + public void save( + String rawCode, + MobileOAuthGrant grant, + String redirectUri, + String codeChallenge + ) { + Map fields = new HashMap<>(); + + fields.put(FIELD_RESULT, grant.result().name()); + fields.put(FIELD_PROVIDER, grant.provider() == null ? "" : grant.provider().name()); + fields.put(FIELD_MEMBER_ID, grant.memberId() == null ? "" : String.valueOf(grant.memberId())); + fields.put(FIELD_TICKET_ID, blank(grant.ticketId())); + fields.put(FIELD_ERROR_CODE, blank(grant.errorCode())); + fields.put(FIELD_ISSUE_TOKENS, String.valueOf(grant.issueTokens())); + fields.put(FIELD_REDIRECT_URI, redirectUri); + fields.put(FIELD_CODE_CHALLENGE, codeChallenge); + + String key = key(rawCode); + + redisTemplate.opsForHash().putAll(key, fields); + redisTemplate.expire(key, properties.mobile().codeTtl()); + } + + public MobileOAuthGrant consume( + String rawCode, + String redirectUri, + String calculatedChallenge + ) { + if (rawCode == null || rawCode.isBlank() || rawCode.length() > 200) { + throw new NalssiLogException(AuthErrorCode.AUTH_MOBILE_CODE_INVALID); + } + + @SuppressWarnings("unchecked") + List values = redisTemplate.execute( + CONSUME_SCRIPT, + List.of(key(rawCode)), + redirectUri, + calculatedChallenge); + String status = value(values, 0); + + if ("REDIRECT_MISMATCH".equals(status)) { + throw new NalssiLogException(AuthErrorCode.AUTH_REDIRECT_URI_INVALID); + } + if ("PKCE_MISMATCH".equals(status)) { + throw new NalssiLogException(AuthErrorCode.AUTH_PKCE_VERIFICATION_FAILED); + } + if (!"OK".equals(status)) { + throw new NalssiLogException(AuthErrorCode.AUTH_MOBILE_CODE_INVALID); + } + + return new MobileOAuthGrant( + MobileAuthResult.valueOf(value(values, 1)), + enumValue(Provider.class, value(values, 2)), + longValue(value(values, 3)), + nullIfBlank(value(values, 4)), + nullIfBlank(value(values, 5)), + Boolean.parseBoolean(value(values, 6))); + } + + private String key(String rawCode) { + + return KEY_PREFIX + SecretFingerprint.sha256(rawCode); + } + + private String blank(String value) { + + return value == null ? "" : value; + } + + private String value(List values, int index) { + if (values == null || index >= values.size() || values.get(index) == null) { + + return ""; + } + + return String.valueOf(values.get(index)); + } + + private Long longValue(String value) { + + return value == null || value.isBlank() ? null : Long.valueOf(value); + } + + private String nullIfBlank(String value) { + + return value == null || value.isBlank() ? null : value; + } + + private > T enumValue(Class type, String value) { + + return value == null || value.isBlank() ? null : Enum.valueOf(type, value); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthGrant.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthGrant.java new file mode 100644 index 0000000..c097dae --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthGrant.java @@ -0,0 +1,47 @@ +package com.nalssilog.auth.mobile.oauth; + +import com.nalssilog.member.domain.Provider; + +public record MobileOAuthGrant( + MobileAuthResult result, + Provider provider, + Long memberId, + String ticketId, + String errorCode, + boolean issueTokens +) { + + public static MobileOAuthGrant success(Long memberId, Provider provider) { + + return new MobileOAuthGrant( + MobileAuthResult.SUCCESS, provider, memberId, null, null, true); + } + + public static MobileOAuthGrant signupRequired(Provider provider, String ticketId) { + + return new MobileOAuthGrant( + MobileAuthResult.SIGNUP_REQUIRED, provider, null, ticketId, null, false); + } + + public static MobileOAuthGrant linkRequired(Provider provider, String ticketId) { + + return new MobileOAuthGrant( + MobileAuthResult.LINK_REQUIRED, provider, null, ticketId, null, false); + } + + public static MobileOAuthGrant linkSuccess( + Long memberId, + Provider provider, + boolean issueTokens + ) { + + return new MobileOAuthGrant( + MobileAuthResult.LINK_SUCCESS, provider, memberId, null, null, issueTokens); + } + + public static MobileOAuthGrant failed(Provider provider, String errorCode) { + + return new MobileOAuthGrant( + MobileAuthResult.FAILED, provider, null, null, errorCode, false); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthPurpose.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthPurpose.java new file mode 100644 index 0000000..f97a559 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthPurpose.java @@ -0,0 +1,7 @@ +package com.nalssilog.auth.mobile.oauth; + +public enum MobileOAuthPurpose { + LOGIN, + LOGIN_LINK_REAUTH, + SETTINGS_LINK +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthRequestAttributes.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthRequestAttributes.java new file mode 100644 index 0000000..62ce9a2 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthRequestAttributes.java @@ -0,0 +1,28 @@ +package com.nalssilog.auth.mobile.oauth; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; + +public final class MobileOAuthRequestAttributes { + + public static final String TRANSACTION_PARAMETER = "mobile_transaction"; + public static final String AUTHORIZATION_ATTRIBUTE = + MobileOAuthRequestAttributes.class.getName() + ".authorizationTransaction"; + private static final String REQUEST_ATTRIBUTE = + MobileOAuthRequestAttributes.class.getName() + ".requestTransaction"; + + private MobileOAuthRequestAttributes() { + } + + public static void expose(HttpServletRequest request, String transactionId) { + request.setAttribute(REQUEST_ATTRIBUTE, transactionId); + } + + public static Optional transactionId(HttpServletRequest request) { + Object value = request.getAttribute(REQUEST_ATTRIBUTE); + + return value instanceof String text && !text.isBlank() + ? Optional.of(text) + : Optional.empty(); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java new file mode 100644 index 0000000..be80f2e --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java @@ -0,0 +1,514 @@ +package com.nalssilog.auth.mobile.oauth; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.oauth.OAuthUserInfo; +import com.nalssilog.auth.oauth.SocialPrincipal; +import com.nalssilog.auth.ticket.AuthChannel; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.ticket.LinkTicket; +import com.nalssilog.auth.ticket.SignupTicket; +import com.nalssilog.auth.token.AuthTokenService; +import com.nalssilog.auth.token.TokenPair; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.dto.MemberInfo; +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.security.SecureRandom; +import java.util.Base64; +import java.util.List; +import java.util.Locale; +import java.util.Optional; +import java.util.UUID; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.stereotype.Service; +import org.springframework.web.util.UriComponentsBuilder; + +@Service +@RequiredArgsConstructor +public class MobileOAuthService { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int OPAQUE_BYTES = 32; + private static final Pattern CODE_CHALLENGE_PATTERN = + Pattern.compile("^[A-Za-z0-9_-]{43}$"); + private static final Pattern CODE_VERIFIER_PATTERN = + Pattern.compile("^[A-Za-z0-9\\-._~]{43,128}$"); + private static final Pattern STATE_PATTERN = + Pattern.compile("^[A-Za-z0-9_-]{16,256}$"); + + private final MobileOAuthTransactionStore transactionStore; + private final MobileOAuthCodeStore codeStore; + private final AuthTicketStore ticketStore; + private final MemberClient memberClient; + private final AuthTokenService authTokenService; + private final AuthProperties properties; + private final ClientRegistrationRepository clientRegistrationRepository; + + public String startLogin( + String providerText, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String appState + ) { + Provider provider = provider(providerText); + + validateStart(provider, redirectUri, codeChallenge, codeChallengeMethod, appState); + + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN, + provider, + redirectUri, + codeChallenge, + appState, + null, + null); + + return saveAndAuthorizationUrl(transaction); + } + + public String startLinkReauthentication( + String linkTicketId, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String appState + ) { + LinkTicket ticket = mobileLinkTicket(linkTicketId); + + ticketStore.markLinkConsented(linkTicketId, properties.ticket().ttl()); + + MemberInfo target = memberClient.getMemberInfo(ticket.targetMemberId()); + Provider reauthenticationProvider = target.lastLoginProvider() != null + ? target.lastLoginProvider() + : target.connectedProviders().getFirst(); + + validateStart( + reauthenticationProvider, + redirectUri, + codeChallenge, + codeChallengeMethod, + appState); + + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN_LINK_REAUTH, + reauthenticationProvider, + redirectUri, + codeChallenge, + appState, + linkTicketId, + ticket.targetMemberId()); + + return saveAndAuthorizationUrl(transaction); + } + + public String startSettingsLink( + Long memberId, + String providerText, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String appState + ) { + Provider provider = provider(providerText); + MemberInfo member = memberClient.getMemberInfo(memberId); + + if (member.connectedProviders().contains(provider)) { + throw new NalssiLogException(AuthErrorCode.ALREADY_LINKED_PROVIDER); + } + validateStart(provider, redirectUri, codeChallenge, codeChallengeMethod, appState); + + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.SETTINGS_LINK, + provider, + redirectUri, + codeChallenge, + appState, + null, + memberId); + + return saveAndAuthorizationUrl(transaction); + } + + public void cancelLink(String linkTicketId) { + LinkTicket ticket = mobileLinkTicket(linkTicketId); + + ticketStore.deleteLink(linkTicketId); + ticketStore.deleteLinkConsent(linkTicketId); + } + + public String complete(String transactionId, SocialPrincipal principal) { + MobileOAuthTransaction transaction = transactionStore.take(transactionId) + .orElseThrow(() -> new NalssiLogException( + AuthErrorCode.AUTH_MOBILE_TRANSACTION_EXPIRED)); + MobileOAuthGrant grant; + + if (transaction.provider() != principal.userInfo().provider()) { + grant = MobileOAuthGrant.failed(transaction.provider(), AuthErrorCode.OAUTH_FAILED.getCode()); + } else { + grant = switch (transaction.purpose()) { + case LOGIN -> completeLogin(principal); + case LOGIN_LINK_REAUTH -> completeLoginLink(transaction, principal); + case SETTINGS_LINK -> completeSettingsLink(transaction, principal); + }; + } + + return issueCallback(transaction, grant); + } + + public Optional completeFailure(String transactionId, String errorCode) { + + return transactionStore.take(transactionId) + .map(transaction -> issueCallback( + transaction, + MobileOAuthGrant.failed(transaction.provider(), errorCode))); + } + + public ExchangeResult exchange( + String rawCode, + String codeVerifier, + String redirectUri, + com.nalssilog.auth.device.DeviceInfo device + ) { + validateRedirectUri(redirectUri); + if (codeVerifier == null || !CODE_VERIFIER_PATTERN.matcher(codeVerifier).matches()) { + throw new NalssiLogException(AuthErrorCode.AUTH_PKCE_VERIFICATION_FAILED); + } + + MobileOAuthGrant grant = codeStore.consume( + rawCode, + redirectUri, + codeChallenge(codeVerifier)); + + return switch (grant.result()) { + case SUCCESS -> authenticated(grant, MobileAuthResult.SUCCESS, device); + case LINK_SUCCESS -> grant.issueTokens() + ? authenticated(grant, MobileAuthResult.LINK_SUCCESS, device) + : linkedWithoutTokens(grant); + case SIGNUP_REQUIRED -> signupRequired(grant); + case LINK_REQUIRED -> linkRequired(grant); + case FAILED -> throw failure(grant.errorCode()); + }; + } + + public LinkTicket mobileLinkTicket(String ticketId) { + if (ticketId == null || ticketId.isBlank()) { + throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); + } + + LinkTicket ticket = ticketStore.findLink(ticketId) + .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); + + requireMobileChannel(ticket.effectiveChannel()); + + return ticket; + } + + public SignupTicket mobileSignupTicket(String ticketId) { + if (ticketId == null || ticketId.isBlank()) { + throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); + } + + SignupTicket ticket = ticketStore.findSignup(ticketId) + .orElseThrow(() -> new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND)); + + requireMobileChannel(ticket.effectiveChannel()); + + return ticket; + } + + private MobileOAuthGrant completeLogin(SocialPrincipal principal) { + OAuthUserInfo userInfo = principal.userInfo(); + + return switch (principal.result().outcome()) { + case EXISTING -> principal.result().status() == MemberStatus.WITHDRAWN + ? MobileOAuthGrant.failed(userInfo.provider(), AuthErrorCode.OAUTH_FAILED.getCode()) + : MobileOAuthGrant.success(principal.result().memberId(), userInfo.provider()); + case NEW -> { + String ticketId = UUID.randomUUID().toString(); + + ticketStore.saveSignup( + ticketId, + new SignupTicket( + userInfo.provider(), + userInfo.providerUserId(), + userInfo.email(), + userInfo.socialName(), + AuthChannel.MOBILE), + properties.ticket().ttl()); + yield MobileOAuthGrant.signupRequired(userInfo.provider(), ticketId); + } + case LINK_REQUIRED -> { + String ticketId = UUID.randomUUID().toString(); + + ticketStore.saveLink( + ticketId, + new LinkTicket( + userInfo.provider(), + userInfo.providerUserId(), + userInfo.email(), + principal.result().memberId(), + principal.result().existingProviders(), + AuthChannel.MOBILE), + properties.ticket().ttl()); + yield MobileOAuthGrant.linkRequired(userInfo.provider(), ticketId); + } + }; + } + + private MobileOAuthGrant completeLoginLink( + MobileOAuthTransaction transaction, + SocialPrincipal principal + ) { + String ticketId = transaction.referenceId(); + + try { + LinkTicket ticket = mobileLinkTicket(ticketId); + boolean validOwner = principal.result().outcome() + == com.nalssilog.member.application.dto.SocialLoginResult.Outcome.EXISTING + && ticket.targetMemberId().equals(principal.result().memberId()) + && ticket.targetMemberId().equals(transaction.targetMemberId()) + && ticketStore.isLinkConsented(ticketId); + + if (!validOwner) { + + return MobileOAuthGrant.failed( + transaction.provider(), AuthErrorCode.OAUTH_FAILED.getCode()); + } + + MemberInfo member = memberClient.linkSocial( + ticket.targetMemberId(), + new OAuthUserInfo( + ticket.provider(), + ticket.providerUserId(), + ticket.email(), + null)); + + return MobileOAuthGrant.linkSuccess( + member.id(), + principal.userInfo().provider(), + true); + } catch (NalssiLogException _) { + + return MobileOAuthGrant.failed( + transaction.provider(), AuthErrorCode.OAUTH_FAILED.getCode()); + } finally { + if (ticketId != null) { + ticketStore.deleteLink(ticketId); + ticketStore.deleteLinkConsent(ticketId); + } + } + } + + private MobileOAuthGrant completeSettingsLink( + MobileOAuthTransaction transaction, + SocialPrincipal principal + ) { + try { + MemberInfo member = memberClient.linkSocial( + transaction.targetMemberId(), + principal.userInfo()); + + return MobileOAuthGrant.linkSuccess( + member.id(), + principal.userInfo().provider(), + false); + } catch (NalssiLogException _) { + + return MobileOAuthGrant.failed( + transaction.provider(), AuthErrorCode.OAUTH_FAILED.getCode()); + } + } + + private ExchangeResult authenticated( + MobileOAuthGrant grant, + MobileAuthResult result, + com.nalssilog.auth.device.DeviceInfo device + ) { + MemberInfo member = memberClient.getMemberInfo(grant.memberId()); + + if (member.status() == MemberStatus.WITHDRAWN) { + throw new NalssiLogException(AuthErrorCode.OAUTH_FAILED); + } + + TokenPair tokens = authTokenService.issue( + member.id(), + member.status(), + grant.provider(), + device); + + return new ExchangeResult( + result, tokens, member, null, null, null, null, List.of()); + } + + private ExchangeResult linkedWithoutTokens(MobileOAuthGrant grant) { + MemberInfo member = memberClient.getMemberInfo(grant.memberId()); + + return new ExchangeResult( + MobileAuthResult.LINK_SUCCESS, + null, + member, + null, + null, + null, + null, + List.of()); + } + + private ExchangeResult signupRequired(MobileOAuthGrant grant) { + SignupTicket ticket = mobileSignupTicket(grant.ticketId()); + + return new ExchangeResult( + MobileAuthResult.SIGNUP_REQUIRED, + null, + null, + grant.ticketId(), + null, + ticket.provider(), + ticket.email(), + List.of()); + } + + private ExchangeResult linkRequired(MobileOAuthGrant grant) { + LinkTicket ticket = mobileLinkTicket(grant.ticketId()); + + return new ExchangeResult( + MobileAuthResult.LINK_REQUIRED, + null, + null, + null, + grant.ticketId(), + ticket.provider(), + ticket.email(), + ticket.existingProviders()); + } + + private NalssiLogException failure(String errorCode) { + if (AuthErrorCode.OAUTH_CANCELLED.getCode().equals(errorCode)) { + + return new NalssiLogException(AuthErrorCode.OAUTH_CANCELLED); + } + if (AuthErrorCode.OAUTH_EMAIL_REQUIRED.getCode().equals(errorCode)) { + + return new NalssiLogException(AuthErrorCode.OAUTH_EMAIL_REQUIRED); + } + + return new NalssiLogException(AuthErrorCode.OAUTH_FAILED); + } + + private String saveAndAuthorizationUrl(MobileOAuthTransaction transaction) { + String transactionId = opaqueValue(); + + transactionStore.save(transactionId, transaction); + + return UriComponentsBuilder + .fromPath("/oauth2/authorization/" + + transaction.provider().name().toLowerCase(Locale.ROOT)) + .queryParam(MobileOAuthRequestAttributes.TRANSACTION_PARAMETER, transactionId) + .build() + .encode() + .toUriString(); + } + + private String issueCallback( + MobileOAuthTransaction transaction, + MobileOAuthGrant grant + ) { + String code = opaqueValue(); + + codeStore.save(code, grant, transaction.redirectUri(), transaction.codeChallenge()); + + return UriComponentsBuilder + .fromUriString(transaction.redirectUri()) + .queryParam("code", code) + .queryParam("state", transaction.appState()) + .build() + .encode() + .toUriString(); + } + + private void validateStart( + Provider provider, + String redirectUri, + String codeChallenge, + String codeChallengeMethod, + String appState + ) { + validateRedirectUri(redirectUri); + if (!"S256".equals(codeChallengeMethod) + || codeChallenge == null + || !CODE_CHALLENGE_PATTERN.matcher(codeChallenge).matches()) { + throw new NalssiLogException(AuthErrorCode.AUTH_PKCE_VERIFICATION_FAILED); + } + if (appState == null + || !STATE_PATTERN.matcher(appState).matches()) { + throw new NalssiLogException(AuthErrorCode.OAUTH_FAILED); + } + + String registrationId = provider.name().toLowerCase(Locale.ROOT); + + if (clientRegistrationRepository.findByRegistrationId(registrationId) == null) { + throw new NalssiLogException(AuthErrorCode.UNSUPPORTED_PROVIDER); + } + } + + private void validateRedirectUri(String redirectUri) { + if (redirectUri == null + || !properties.mobile().redirectUris().contains(redirectUri)) { + throw new NalssiLogException(AuthErrorCode.AUTH_REDIRECT_URI_INVALID); + } + } + + private Provider provider(String text) { + try { + + return Provider.from(text); + } catch (IllegalArgumentException exception) { + throw new NalssiLogException(AuthErrorCode.UNSUPPORTED_PROVIDER); + } + } + + private void requireMobileChannel(AuthChannel channel) { + if (channel != AuthChannel.MOBILE) { + throw new NalssiLogException(AuthErrorCode.AUTH_TICKET_CHANNEL_MISMATCH); + } + } + + private String codeChallenge(String verifier) { + try { + byte[] digest = MessageDigest.getInstance("SHA-256") + .digest(verifier.getBytes(StandardCharsets.US_ASCII)); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(digest); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("SHA-256 not available", exception); + } + } + + private String opaqueValue() { + byte[] bytes = new byte[OPAQUE_BYTES]; + + SECURE_RANDOM.nextBytes(bytes); + + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } + + public record ExchangeResult( + MobileAuthResult result, + TokenPair tokens, + MemberInfo member, + String signupTicket, + String linkTicket, + Provider pendingProvider, + String pendingEmail, + List existingProviders + ) { + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransaction.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransaction.java new file mode 100644 index 0000000..9b6b318 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransaction.java @@ -0,0 +1,14 @@ +package com.nalssilog.auth.mobile.oauth; + +import com.nalssilog.member.domain.Provider; + +public record MobileOAuthTransaction( + MobileOAuthPurpose purpose, + Provider provider, + String redirectUri, + String codeChallenge, + String appState, + String referenceId, + Long targetMemberId +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransactionStore.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransactionStore.java new file mode 100644 index 0000000..cb8ceff --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransactionStore.java @@ -0,0 +1,82 @@ +package com.nalssilog.auth.mobile.oauth; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.common.security.SecretFingerprint; +import java.util.Optional; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Repository +@RequiredArgsConstructor +public class MobileOAuthTransactionStore { + + private static final String KEY_PREFIX = "auth:mobile:transaction:"; + private static final Pattern TRANSACTION_ID_PATTERN = + Pattern.compile("^[A-Za-z0-9_-]{43}$"); + + private final StringRedisTemplate redisTemplate; + private final ObjectMapper objectMapper; + private final AuthProperties properties; + + public void save(String transactionId, MobileOAuthTransaction transaction) { + redisTemplate.opsForValue().set( + key(transactionId), + serialize(transaction), + properties.mobile().transactionTtl()); + } + + public Optional find(String transactionId) { + if (!validTransactionId(transactionId)) { + + return Optional.empty(); + } + + return deserialize(redisTemplate.opsForValue().get(key(transactionId))); + } + + public Optional take(String transactionId) { + if (!validTransactionId(transactionId)) { + + return Optional.empty(); + } + + return deserialize(redisTemplate.opsForValue().getAndDelete(key(transactionId))); + } + + private String key(String transactionId) { + + return KEY_PREFIX + SecretFingerprint.sha256(transactionId); + } + + private boolean validTransactionId(String transactionId) { + + return transactionId != null + && TRANSACTION_ID_PATTERN.matcher(transactionId).matches(); + } + + private String serialize(MobileOAuthTransaction transaction) { + try { + + return objectMapper.writeValueAsString(transaction); + } catch (JacksonException exception) { + throw new IllegalStateException("mobile OAuth transaction serialization failed", exception); + } + } + + private Optional deserialize(String value) { + if (value == null) { + + return Optional.empty(); + } + try { + + return Optional.of(objectMapper.readValue(value, MobileOAuthTransaction.class)); + } catch (JacksonException exception) { + throw new IllegalStateException("mobile OAuth transaction deserialization failed", exception); + } + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/application/CustomOAuth2UserService.java b/auth/src/main/java/com/nalssilog/auth/oauth/CustomOAuth2UserService.java similarity index 93% rename from auth/src/main/java/com/nalssilog/auth/application/CustomOAuth2UserService.java rename to auth/src/main/java/com/nalssilog/auth/oauth/CustomOAuth2UserService.java index 5abf7cb..afc136e 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/CustomOAuth2UserService.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/CustomOAuth2UserService.java @@ -1,7 +1,6 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.oauth; -import com.nalssilog.auth.client.MemberClient; -import com.nalssilog.auth.client.OAuthUserInfo; +import com.nalssilog.auth.member.MemberClient; import com.nalssilog.member.application.dto.SocialLoginResult; import java.util.Objects; import lombok.RequiredArgsConstructor; @@ -27,6 +26,7 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic String registrationId = userRequest.getClientRegistration().getRegistrationId(); OAuthUserInfo userInfo = OAuthUserInfo.of(registrationId, oAuth2User.getAttributes()); SocialLoginResult result = memberClient.resolveSocialLogin(userInfo); + requireEmailForOnboarding(result, userInfo.email()); return new SocialAuthPrincipal(result, userInfo, oAuth2User.getAttributes()); diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/CustomOidcUserService.java b/auth/src/main/java/com/nalssilog/auth/oauth/CustomOidcUserService.java new file mode 100644 index 0000000..0d83b91 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/CustomOidcUserService.java @@ -0,0 +1,46 @@ +package com.nalssilog.auth.oauth; + +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.oauth.apple.AppleAuthorizationUserContext; +import com.nalssilog.member.application.dto.SocialLoginResult; +import java.util.Objects; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; +import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.stereotype.Service; + +/** + * Loads OpenID Connect providers, including Sign in with Apple, through the same member resolution + * policy used by classic OAuth2 providers. + */ +@Service +@RequiredArgsConstructor +public class CustomOidcUserService extends OidcUserService { + + private final MemberClient memberClient; + private final AppleAuthorizationUserContext appleAuthorizationUserContext; + + @Override + public OidcUser loadUser(OidcUserRequest userRequest) + throws OAuth2AuthenticationException { + OidcUser oidcUser = Objects.requireNonNull( + super.loadUser(userRequest), + "OidcUser must not be null"); + String registrationId = + userRequest.getClientRegistration().getRegistrationId(); + OAuthUserInfo userInfo = OAuthUserInfo.of( + registrationId, + oidcUser.getClaims(), + appleAuthorizationUserContext.currentSocialName().orElse(null)); + SocialLoginResult result = + memberClient.resolveSocialLogin(userInfo); + + CustomOAuth2UserService.requireEmailForOnboarding( + result, + userInfo.email()); + + return new SocialOidcPrincipal(result, userInfo, oidcUser); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginFailureHandler.java b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java similarity index 54% rename from auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginFailureHandler.java rename to auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java index cb601e4..c39df54 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/OAuth2LoginFailureHandler.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java @@ -1,10 +1,13 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.oauth; -import static com.nalssilog.auth.application.CustomOAuth2UserService.EMAIL_REQUIRED_ERROR; +import static com.nalssilog.auth.oauth.CustomOAuth2UserService.EMAIL_REQUIRED_ERROR; +import com.nalssilog.auth.mobile.oauth.MobileOAuthRequestAttributes; +import com.nalssilog.auth.mobile.oauth.MobileOAuthService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.core.AuthenticationException; @@ -12,27 +15,47 @@ import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.stereotype.Component; -/** - * 소셜 인증 실패 시에도 성공과 동일한 단일 콜백으로 보낸다. 사용자가 provider 에서 취소하면 OAUTH_CANCELLED. - */ @Slf4j @Component +@RequiredArgsConstructor public class OAuth2LoginFailureHandler implements AuthenticationFailureHandler { + private final MobileOAuthService mobileOAuthService; + @Value("${nalssilog.frontend.base-url}") private String frontendBaseUrl; @Override - public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, - AuthenticationException exception) throws IOException { + public void onAuthenticationFailure( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception + ) throws IOException { String code = resolveCode(exception); - log.warn("OAuth2 login failed ({}): {}", code, exception.getMessage()); + + log.warn("OAuth2 login failed code={} type={}", + code, exception.getClass().getSimpleName()); + + var mobileTransaction = MobileOAuthRequestAttributes.transactionId(request); + + if (mobileTransaction.isPresent()) { + var callback = mobileOAuthService.completeFailure( + mobileTransaction.get(), + code); + + if (callback.isPresent()) { + response.sendRedirect(callback.get()); + + return; + } + } response.sendRedirect(frontendBaseUrl + "/auth/callback?result=FAILED&code=" + code); } private String resolveCode(AuthenticationException exception) { if (exception instanceof OAuth2AuthenticationException oauthException) { + return switch (oauthException.getError().getErrorCode()) { case "access_denied" -> "OAUTH_CANCELLED"; case EMAIL_REQUIRED_ERROR -> "OAUTH_EMAIL_REQUIRED"; diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java new file mode 100644 index 0000000..b816c06 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java @@ -0,0 +1,106 @@ +package com.nalssilog.auth.oauth; + +import com.nalssilog.auth.device.DeviceInfoResolver; +import com.nalssilog.auth.mobile.oauth.MobileOAuthRequestAttributes; +import com.nalssilog.auth.mobile.oauth.MobileOAuthService; +import com.nalssilog.auth.oauth.WebOAuthService.Completion; +import com.nalssilog.auth.web.AuthCookieManager; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Objects; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; +import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +@Component +@RequiredArgsConstructor +public class OAuth2LoginSuccessHandler implements AuthenticationSuccessHandler { + + private final AuthCookieManager cookieManager; + private final DeviceInfoResolver deviceInfoResolver; + private final MobileOAuthService mobileOAuthService; + private final WebOAuthService webOAuthService; + + @Value("${nalssilog.frontend.base-url}") + private String frontendBaseUrl; + + @Override + public void onAuthenticationSuccess( + HttpServletRequest request, + HttpServletResponse response, + Authentication authentication + ) throws IOException { + SocialPrincipal principal = Objects.requireNonNull( + (SocialPrincipal) authentication.getPrincipal(), + "principal must not be null"); + + Optional mobileTransaction = + MobileOAuthRequestAttributes.transactionId(request); + + if (mobileTransaction.isPresent()) { + response.sendRedirect(mobileOAuthService.complete( + mobileTransaction.get(), + principal)); + + return; + } + + Completion completion = webOAuthService.complete( + principal, + cookieManager.readLinkIntent(request), + cookieManager.readLinkTicket(request), + deviceInfoResolver.resolve(request)); + + applyCookies(response, completion); + response.sendRedirect(callbackUrl( + completion.result(), + completion.errorCode())); + } + + private void applyCookies( + HttpServletResponse response, + Completion completion + ) { + if (completion.clearLinkIntent()) { + cookieManager.clearLinkIntentCookie(response); + } + if (completion.clearLinkTicket()) { + cookieManager.clearLinkTicketCookie(response); + } + if (completion.signupTicket() != null) { + cookieManager.addSignupTicketCookie( + response, + completion.signupTicket()); + } + if (completion.linkTicket() != null) { + cookieManager.addLinkTicketCookie( + response, + completion.linkTicket()); + } + if (completion.tokens() != null) { + cookieManager.addAuthCookies( + response, + completion.tokens().accessToken(), + completion.tokens().refreshToken(), + completion.tokens().refreshTokenMaxAge()); + } + } + + private String callbackUrl(String result, String errorCode) { + UriComponentsBuilder builder = UriComponentsBuilder + .fromUriString(frontendBaseUrl) + .path("/auth/callback") + .queryParam("result", result); + + if (errorCode != null && !errorCode.isBlank()) { + builder.queryParam("code", errorCode); + } + + return builder.build().encode().toUriString(); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/client/OAuthUserInfo.java b/auth/src/main/java/com/nalssilog/auth/oauth/OAuthUserInfo.java similarity index 74% rename from auth/src/main/java/com/nalssilog/auth/client/OAuthUserInfo.java rename to auth/src/main/java/com/nalssilog/auth/oauth/OAuthUserInfo.java index 3a9892e..8b85fe8 100644 --- a/auth/src/main/java/com/nalssilog/auth/client/OAuthUserInfo.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/OAuthUserInfo.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.client; +package com.nalssilog.auth.oauth; import com.nalssilog.member.domain.Provider; import java.util.Map; @@ -17,6 +17,16 @@ public record OAuthUserInfo( @SuppressWarnings("unchecked") public static OAuthUserInfo of(String registrationId, Map attributes) { + + return of(registrationId, attributes, null); + } + + @SuppressWarnings("unchecked") + public static OAuthUserInfo of( + String registrationId, + Map attributes, + String appleSocialName + ) { Provider provider = Provider.from(registrationId); return switch (provider) { @@ -48,10 +58,22 @@ yield new OAuthUserInfo( name == null || name.isBlank() ? text(response.get("nickname")) : name ); } + case APPLE -> new OAuthUserInfo( + provider, + text(attributes.get("sub")), + text(attributes.get(EMAIL_KEY)), + firstNonBlank(appleSocialName, text(attributes.get("name"))) + ); }; } + private static String firstNonBlank(String first, String second) { + + return first == null || first.isBlank() ? second : first; + } + private static String text(Object value) { + return value == null ? null : String.valueOf(value); } } diff --git a/auth/src/main/java/com/nalssilog/auth/application/SocialAuthPrincipal.java b/auth/src/main/java/com/nalssilog/auth/oauth/SocialAuthPrincipal.java similarity index 92% rename from auth/src/main/java/com/nalssilog/auth/application/SocialAuthPrincipal.java rename to auth/src/main/java/com/nalssilog/auth/oauth/SocialAuthPrincipal.java index 94a9bd6..e95a7fd 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/SocialAuthPrincipal.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/SocialAuthPrincipal.java @@ -1,6 +1,5 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.oauth; -import com.nalssilog.auth.client.OAuthUserInfo; import com.nalssilog.member.application.dto.SocialLoginResult; import com.nalssilog.member.domain.MemberStatus; import java.util.Collection; @@ -19,24 +18,28 @@ public record SocialAuthPrincipal( SocialLoginResult result, OAuthUserInfo userInfo, Map attributes -) implements OAuth2User { +) implements SocialPrincipal { @Override public Map getAttributes() { + return attributes; } @Override public Collection getAuthorities() { + return List.of(new SimpleGrantedAuthority(roleOf(result.status()))); } @Override public String getName() { + return result.memberId() == null ? "anonymous" : String.valueOf(result.memberId()); } public static String roleOf(MemberStatus status) { + return status == MemberStatus.ACTIVE ? "ROLE_MEMBER" : "ROLE_PENDING"; } } diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/SocialOidcPrincipal.java b/auth/src/main/java/com/nalssilog/auth/oauth/SocialOidcPrincipal.java new file mode 100644 index 0000000..49671bd --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/SocialOidcPrincipal.java @@ -0,0 +1,62 @@ +package com.nalssilog.auth.oauth; + +import com.nalssilog.member.application.dto.SocialLoginResult; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.oidc.OidcIdToken; +import org.springframework.security.oauth2.core.oidc.OidcUserInfo; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; + +/** + * OIDC counterpart of {@link SocialAuthPrincipal}. The signed ID token is retained so Spring's + * OIDC authentication contract remains intact while the application receives normalized social + * identity data. + */ +public record SocialOidcPrincipal( + SocialLoginResult result, + OAuthUserInfo userInfo, + OidcUser delegate +) implements SocialPrincipal, OidcUser { + + @Override + public Map getClaims() { + + return delegate.getClaims(); + } + + @Override + public OidcUserInfo getUserInfo() { + + return delegate.getUserInfo(); + } + + @Override + public OidcIdToken getIdToken() { + + return delegate.getIdToken(); + } + + @Override + public Map getAttributes() { + + return delegate.getAttributes(); + } + + @Override + public Collection getAuthorities() { + + return List.of(new SimpleGrantedAuthority( + SocialAuthPrincipal.roleOf(result.status()))); + } + + @Override + public String getName() { + + return result.memberId() == null + ? delegate.getName() + : String.valueOf(result.memberId()); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/SocialPrincipal.java b/auth/src/main/java/com/nalssilog/auth/oauth/SocialPrincipal.java new file mode 100644 index 0000000..0005ea1 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/SocialPrincipal.java @@ -0,0 +1,14 @@ +package com.nalssilog.auth.oauth; + +import com.nalssilog.member.application.dto.SocialLoginResult; +import org.springframework.security.oauth2.core.user.OAuth2User; + +/** + * Provider-neutral principal passed from OAuth2/OIDC user loading to the completion service. + */ +public interface SocialPrincipal extends OAuth2User { + + SocialLoginResult result(); + + OAuthUserInfo userInfo(); +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java b/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java new file mode 100644 index 0000000..7efe396 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java @@ -0,0 +1,254 @@ +package com.nalssilog.auth.oauth; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.ticket.AuthChannel; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.ticket.LinkTicket; +import com.nalssilog.auth.ticket.SignupTicket; +import com.nalssilog.auth.token.AuthTokenService; +import com.nalssilog.auth.token.TokenPair; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.domain.MemberStatus; +import java.util.Optional; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +/** + * Completes browser OAuth flows without depending on servlet APIs. + * + *

The success handler is an HTTP adapter: it reads and writes cookies and performs the final + * redirect. Ticket decisions, account linking, and token issuance belong here. + */ +@Service +@RequiredArgsConstructor +public class WebOAuthService { + + private static final String SUCCESS = "SUCCESS"; + private static final String FAILED = "FAILED"; + private static final String SIGNUP_REQUIRED = "SIGNUP_REQUIRED"; + private static final String LINK_REQUIRED = "LINK_REQUIRED"; + private static final String LINK_SUCCESS = "LINK_SUCCESS"; + private static final String LINK_FAILED = "LINK_FAILED"; + + private final AuthTokenService authTokenService; + private final AuthTicketStore ticketStore; + private final MemberClient memberClient; + private final AuthProperties properties; + + public Completion complete( + SocialPrincipal principal, + Optional linkIntentId, + Optional pendingLinkTicketId, + DeviceInfo device + ) { + if (linkIntentId.isPresent()) { + + return completeSettingsLink(linkIntentId.get(), principal); + } + + return switch (principal.result().outcome()) { + case EXISTING -> completeExisting(principal, pendingLinkTicketId, device); + case NEW -> requireSignup(principal.userInfo()); + case LINK_REQUIRED -> requireLink(principal); + }; + } + + private Completion completeSettingsLink( + String intentId, + SocialPrincipal principal + ) { + Optional memberId = ticketStore.findLinkIntent(intentId); + + ticketStore.deleteLinkIntent(intentId); + + if (memberId.isEmpty()) { + + return Completion.clearIntent(LINK_FAILED, null); + } + + try { + memberClient.linkSocial(memberId.get(), principal.userInfo()); + + return Completion.clearIntent(LINK_SUCCESS, null); + } catch (NalssiLogException exception) { + + return Completion.clearIntent( + LINK_FAILED, + exception.getErrorCode().getCode()); + } + } + + private Completion completeExisting( + SocialPrincipal principal, + Optional pendingLinkTicketId, + DeviceInfo device + ) { + if (principal.result().status() == MemberStatus.WITHDRAWN) { + + return Completion.redirect(FAILED); + } + + if (pendingLinkTicketId.isPresent()) { + + return completeLoginLink( + pendingLinkTicketId.get(), + principal, + device); + } + + return authenticated( + principal.result().memberId(), + principal.result().status(), + principal.userInfo().provider(), + SUCCESS, + device, + false); + } + + private Completion completeLoginLink( + String ticketId, + SocialPrincipal principal, + DeviceInfo device + ) { + LinkTicket ticket = ticketStore.findLink(ticketId).orElse(null); + boolean consented = ticketStore.isLinkConsented(ticketId); + + ticketStore.deleteLink(ticketId); + ticketStore.deleteLinkConsent(ticketId); + + if (ticket == null || !consented) { + + return authenticated( + principal.result().memberId(), + principal.result().status(), + principal.userInfo().provider(), + SUCCESS, + device, + true); + } + + if (!ticket.targetMemberId().equals(principal.result().memberId())) { + + return Completion.clearLinkTicket(LINK_FAILED); + } + + MemberInfo member = memberClient.linkSocial( + ticket.targetMemberId(), + new OAuthUserInfo( + ticket.provider(), + ticket.providerUserId(), + ticket.email(), + null)); + + return authenticated( + member.id(), + member.status(), + principal.userInfo().provider(), + LINK_SUCCESS, + device, + true); + } + + private Completion requireSignup(OAuthUserInfo userInfo) { + String ticketId = UUID.randomUUID().toString(); + + ticketStore.saveSignup( + ticketId, + new SignupTicket( + userInfo.provider(), + userInfo.providerUserId(), + userInfo.email(), + userInfo.socialName(), + AuthChannel.WEB), + properties.ticket().ttl()); + + return Completion.signup(ticketId); + } + + private Completion requireLink(SocialPrincipal principal) { + OAuthUserInfo userInfo = principal.userInfo(); + String ticketId = UUID.randomUUID().toString(); + + ticketStore.saveLink( + ticketId, + new LinkTicket( + userInfo.provider(), + userInfo.providerUserId(), + userInfo.email(), + principal.result().memberId(), + principal.result().existingProviders(), + AuthChannel.WEB), + properties.ticket().ttl()); + + return Completion.link(ticketId); + } + + private Completion authenticated( + Long memberId, + MemberStatus status, + com.nalssilog.member.domain.Provider provider, + String result, + DeviceInfo device, + boolean clearLinkTicket + ) { + TokenPair tokens = authTokenService.issue( + memberId, + status, + provider, + device); + + return new Completion( + result, + null, + tokens, + null, + null, + false, + clearLinkTicket); + } + + public record Completion( + String result, + String errorCode, + TokenPair tokens, + String signupTicket, + String linkTicket, + boolean clearLinkIntent, + boolean clearLinkTicket + ) { + + private static Completion redirect(String result) { + + return new Completion( + result, null, null, null, null, false, false); + } + + private static Completion clearIntent(String result, String errorCode) { + + return new Completion( + result, errorCode, null, null, null, true, false); + } + + private static Completion clearLinkTicket(String result) { + + return new Completion( + result, null, null, null, null, false, true); + } + + private static Completion signup(String ticketId) { + + return new Completion( + SIGNUP_REQUIRED, null, null, ticketId, null, false, false); + } + + private static Completion link(String ticketId) { + + return new Completion( + LINK_REQUIRED, null, null, null, ticketId, false, false); + } + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilter.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilter.java new file mode 100644 index 0000000..7769ac5 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilter.java @@ -0,0 +1,108 @@ +package com.nalssilog.auth.oauth.apple; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpMethod; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AppleAuthorizationResponseFilter extends OncePerRequestFilter { + + public static final String CALLBACK_PATH = "/login/oauth2/code/apple"; + private static final int MAX_USER_JSON_LENGTH = 4_096; + private static final int MAX_SOCIAL_NAME_LENGTH = 100; + + private final ObjectMapper objectMapper; + private final AppleAuthorizationUserContext userContext; + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + + return !HttpMethod.POST.matches(request.getMethod()) + || !CALLBACK_PATH.equals(request.getRequestURI()); + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + try { + userContext.set(readSocialName(request.getParameter("user"))); + filterChain.doFilter(request, response); + } finally { + userContext.clear(); + } + } + + private String readSocialName(String payload) { + if (payload == null || payload.isBlank()) { + + return null; + } + if (payload.length() > MAX_USER_JSON_LENGTH) { + log.warn("auth.apple.user_payload_rejected reason=too_large"); + + return null; + } + + try { + AppleUser user = objectMapper.readValue(payload, AppleUser.class); + + if (user == null || user.name() == null) { + + return null; + } + + String firstName = sanitize(user.name().firstName()); + String lastName = sanitize(user.name().lastName()); + String combined = (firstName + " " + lastName).strip(); + + if (combined.isBlank()) { + + return null; + } + + return combined.length() <= MAX_SOCIAL_NAME_LENGTH + ? combined + : combined.substring(0, MAX_SOCIAL_NAME_LENGTH); + } catch (JacksonException exception) { + log.warn("auth.apple.user_payload_rejected reason=invalid_json"); + + return null; + } + } + + private String sanitize(String value) { + if (value == null) { + + return ""; + } + + return value.codePoints() + .filter(codePoint -> !Character.isISOControl(codePoint)) + .collect( + StringBuilder::new, + StringBuilder::appendCodePoint, + StringBuilder::append) + .toString() + .strip(); + } + + private record AppleUser(AppleName name) { + } + + private record AppleName(String firstName, String lastName) { + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationUserContext.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationUserContext.java new file mode 100644 index 0000000..f82cb4b --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationUserContext.java @@ -0,0 +1,26 @@ +package com.nalssilog.auth.oauth.apple; + +import java.util.Optional; +import org.springframework.stereotype.Component; + +@Component +public class AppleAuthorizationUserContext { + + private final ThreadLocal socialName = new ThreadLocal<>(); + + public void set(String value) { + socialName.remove(); + if (value != null && !value.isBlank()) { + socialName.set(value); + } + } + + public Optional currentSocialName() { + + return Optional.ofNullable(socialName.get()); + } + + public void clear() { + socialName.remove(); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGenerator.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGenerator.java new file mode 100644 index 0000000..20d22d0 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGenerator.java @@ -0,0 +1,130 @@ +package com.nalssilog.auth.oauth.apple; + +import io.jsonwebtoken.Jwts; +import java.nio.charset.StandardCharsets; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.interfaces.ECPrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.util.Base64; +import java.util.Date; +import org.springframework.stereotype.Component; + +@Component +public class AppleClientSecretGenerator { + + private static final String APPLE_AUDIENCE = "https://appleid.apple.com"; + + private final AppleOAuthProperties properties; + private volatile CachedSecret cachedSecret; + + public AppleClientSecretGenerator(AppleOAuthProperties properties) { + this.properties = properties; + } + + public String generate(String clientId) { + properties.requireConfigured(); + if (clientId == null || clientId.isBlank()) { + throw new IllegalStateException("Apple OAuth client-id is missing"); + } + + Instant now = Instant.now(); + CachedSecret current = cachedSecret; + + if (isReusable(current, clientId, now)) { + + return current.value(); + } + + synchronized (this) { + current = cachedSecret; + if (isReusable(current, clientId, now)) { + + return current.value(); + } + + Instant expiresAt = now.plus(properties.clientSecretTtl()); + String value = Jwts.builder() + .header() + .keyId(properties.keyId()) + .and() + .issuer(properties.teamId()) + .subject(clientId) + .audience() + .add(APPLE_AUDIENCE) + .and() + .issuedAt(Date.from(now)) + .expiration(Date.from(expiresAt)) + .signWith(privateKey(), Jwts.SIG.ES256) + .compact(); + + cachedSecret = new CachedSecret(clientId, value, expiresAt); + + return value; + } + } + + private boolean isReusable( + CachedSecret secret, + String clientId, + Instant now + ) { + + return secret != null + && secret.clientId().equals(clientId) + && secret.expiresAt() + .minus(properties.refreshBeforeExpiry()) + .isAfter(now); + } + + private PrivateKey privateKey() { + try { + byte[] encoded = encodedPrivateKey(); + PrivateKey key = KeyFactory.getInstance("EC") + .generatePrivate(new PKCS8EncodedKeySpec(encoded)); + + if (!(key instanceof ECPrivateKey ecPrivateKey) + || ecPrivateKey.getParams().getCurve().getField().getFieldSize() != 256) { + throw new IllegalArgumentException("Apple key must use the P-256 curve"); + } + + return key; + } catch (RuntimeException | java.security.GeneralSecurityException exception) { + throw new IllegalStateException("Apple OAuth private key is invalid", exception); + } + } + + private byte[] encodedPrivateKey() { + if (properties.privateKey() != null + && !properties.privateKey().isBlank()) { + + return decodePemOrBase64(properties.privateKey()); + } + + byte[] decoded = Base64.getDecoder().decode( + properties.privateKeyBase64().replaceAll("\\s", "")); + String possiblePem = new String(decoded, StandardCharsets.UTF_8); + + return possiblePem.contains("-----BEGIN PRIVATE KEY-----") + ? decodePemOrBase64(possiblePem) + : decoded; + } + + private byte[] decodePemOrBase64(String value) { + String normalized = value + .replace("\\n", "\n") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + return Base64.getDecoder().decode(normalized); + } + + private record CachedSecret( + String clientId, + String value, + Instant expiresAt + ) { + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthConfig.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthConfig.java new file mode 100644 index 0000000..038f404 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthConfig.java @@ -0,0 +1,43 @@ +package com.nalssilog.auth.oauth.apple; + +import java.util.Objects; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.client.endpoint.DefaultOAuth2TokenRequestParametersConverter; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.endpoint.RestClientAuthorizationCodeTokenResponseClient; +import org.springframework.security.oauth2.core.endpoint.OAuth2ParameterNames; +import org.springframework.util.MultiValueMap; + +@Configuration +public class AppleOAuthConfig { + + @Bean + public OAuth2AccessTokenResponseClient + authorizationCodeTokenResponseClient( + AppleClientSecretGenerator clientSecretGenerator + ) { + RestClientAuthorizationCodeTokenResponseClient client = + new RestClientAuthorizationCodeTokenResponseClient(); + DefaultOAuth2TokenRequestParametersConverter + defaults = new DefaultOAuth2TokenRequestParametersConverter<>(); + + client.setParametersConverter(request -> { + MultiValueMap parameters = + Objects.requireNonNull(defaults.convert(request)); + + if (AppleOAuthProperties.REGISTRATION_ID.equals( + request.getClientRegistration().getRegistrationId())) { + parameters.set( + OAuth2ParameterNames.CLIENT_SECRET, + clientSecretGenerator.generate( + request.getClientRegistration().getClientId())); + } + + return parameters; + }); + + return client; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProfileValidator.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProfileValidator.java new file mode 100644 index 0000000..6cebed9 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProfileValidator.java @@ -0,0 +1,19 @@ +package com.nalssilog.auth.oauth.apple; + +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; + +@Configuration +@Profile("apple") +@RequiredArgsConstructor +public class AppleOAuthProfileValidator { + + private final AppleOAuthProperties properties; + + @PostConstruct + void validate() { + properties.requireConfigured(); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProperties.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProperties.java new file mode 100644 index 0000000..eff4fc9 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProperties.java @@ -0,0 +1,70 @@ +package com.nalssilog.auth.oauth.apple; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "nalssilog.auth.apple") +public record AppleOAuthProperties( + String teamId, + String keyId, + String privateKey, + String privateKeyBase64, + Duration clientSecretTtl, + Duration refreshBeforeExpiry +) { + + public static final String REGISTRATION_ID = "apple"; + private static final Duration MAX_CLIENT_SECRET_TTL = Duration.ofDays(180); + + public AppleOAuthProperties { + if (clientSecretTtl == null) { + clientSecretTtl = Duration.ofDays(30); + } + if (clientSecretTtl.isNegative() + || clientSecretTtl.isZero() + || clientSecretTtl.compareTo(MAX_CLIENT_SECRET_TTL) > 0) { + throw new IllegalArgumentException( + "Apple OAuth client-secret-ttl must be between 1ms and 180 days"); + } + if (refreshBeforeExpiry == null) { + refreshBeforeExpiry = Duration.ofDays(1); + } + if (refreshBeforeExpiry.isNegative() + || refreshBeforeExpiry.compareTo(clientSecretTtl) >= 0) { + throw new IllegalArgumentException( + "Apple OAuth refresh-before-expiry must be shorter than client-secret-ttl"); + } + } + + public void requireConfigured() { + requireText(teamId, "team-id"); + requireText(keyId, "key-id"); + if (!hasText(privateKey) && !hasText(privateKeyBase64)) { + throw new IllegalStateException( + "Apple OAuth is active but neither private-key nor " + + "private-key-base64 is configured"); + } + } + + private void requireText(String value, String property) { + if (!hasText(value)) { + throw new IllegalStateException( + "Apple OAuth is active but nalssilog.auth.apple." + + property + " is missing"); + } + } + + private boolean hasText(String value) { + + return value != null && !value.isBlank(); + } + + @Override + public String toString() { + + return "AppleOAuthProperties[teamId=, keyId=, " + + "privateKey=, privateKeyBase64=, clientSecretTtl=" + + clientSecretTtl + + ", refreshBeforeExpiry=" + refreshBeforeExpiry + "]"; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/repository/AuthTicketStore.java b/auth/src/main/java/com/nalssilog/auth/repository/AuthTicketStore.java deleted file mode 100644 index 5b5b5ad..0000000 --- a/auth/src/main/java/com/nalssilog/auth/repository/AuthTicketStore.java +++ /dev/null @@ -1,99 +0,0 @@ -package com.nalssilog.auth.repository; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.nalssilog.auth.domain.LinkTicket; -import com.nalssilog.auth.domain.SignupTicket; -import java.time.Duration; -import java.util.Optional; -import lombok.RequiredArgsConstructor; -import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.stereotype.Repository; - -/** 확정 전 상태(가입 대기/연동 대기)를 잠깐 들고 있는 티켓 저장소 (Redis, 단기 TTL, JSON 직렬화). */ -@Repository -@RequiredArgsConstructor -public class AuthTicketStore { - - private static final String SIGNUP_PREFIX = "auth:ticket:signup:"; - private static final String LINK_PREFIX = "auth:ticket:link:"; - private static final String LINK_CONSENT_PREFIX = "auth:ticket:link:consent:"; - private static final String LINK_INTENT_PREFIX = "auth:ticket:link-intent:"; - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); - - private final StringRedisTemplate redisTemplate; - - public void saveSignup(String ticketId, SignupTicket ticket, Duration ttl) { - redisTemplate.opsForValue().set(SIGNUP_PREFIX + ticketId, serialize(ticket), ttl); - } - - public Optional findSignup(String ticketId) { - return read(SIGNUP_PREFIX + ticketId, SignupTicket.class); - } - - public void deleteSignup(String ticketId) { - redisTemplate.delete(SIGNUP_PREFIX + ticketId); - } - - public void saveLink(String ticketId, LinkTicket ticket, Duration ttl) { - redisTemplate.opsForValue().set(LINK_PREFIX + ticketId, serialize(ticket), ttl); - } - - public Optional findLink(String ticketId) { - return read(LINK_PREFIX + ticketId, LinkTicket.class); - } - - public void deleteLink(String ticketId) { - redisTemplate.delete(LINK_PREFIX + ticketId); - } - - /** '연동' 명시 동의 플래그. 이게 있어야만 재인증 후 실제 연동됨(방치 티켓 자동연동 방지). */ - public void markLinkConsented(String ticketId, Duration ttl) { - redisTemplate.opsForValue().set(LINK_CONSENT_PREFIX + ticketId, "1", ttl); - } - - public boolean isLinkConsented(String ticketId) { - return Boolean.TRUE.equals(redisTemplate.hasKey(LINK_CONSENT_PREFIX + ticketId)); - } - - public void deleteLinkConsent(String ticketId) { - redisTemplate.delete(LINK_CONSENT_PREFIX + ticketId); - } - - /** 설정에서 시작한 '소셜 추가 연동' 의도(memberId 를 잠깐 보관, 성공 핸들러가 연동에 사용). */ - public void saveLinkIntent(String intentId, Long memberId, Duration ttl) { - redisTemplate.opsForValue().set(LINK_INTENT_PREFIX + intentId, String.valueOf(memberId), ttl); - } - - public Optional findLinkIntent(String intentId) { - String value = redisTemplate.opsForValue().get(LINK_INTENT_PREFIX + intentId); - - return Optional.ofNullable(value).map(Long::valueOf); - } - - public void deleteLinkIntent(String intentId) { - redisTemplate.delete(LINK_INTENT_PREFIX + intentId); - } - - private Optional read(String key, Class type) { - String value = redisTemplate.opsForValue().get(key); - - if (value == null) { - return Optional.empty(); - } - - try { - return Optional.of(OBJECT_MAPPER.readValue(value, type)); - } catch (JsonProcessingException e) { - throw new IllegalStateException("인증 티켓 역직렬화 실패", e); - } - } - - private String serialize(Object ticket) { - try { - return OBJECT_MAPPER.writeValueAsString(ticket); - } catch (JsonProcessingException e) { - throw new IllegalStateException("인증 티켓 직렬화 실패", e); - } - } -} diff --git a/auth/src/main/java/com/nalssilog/auth/security/ApiAuthenticationEntryPoint.java b/auth/src/main/java/com/nalssilog/auth/security/ApiAuthenticationEntryPoint.java new file mode 100644 index 0000000..3df9761 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/security/ApiAuthenticationEntryPoint.java @@ -0,0 +1,45 @@ +package com.nalssilog.auth.security; + +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.common.exception.ErrorResponse; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; +import tools.jackson.databind.ObjectMapper; + +@Component +@RequiredArgsConstructor +public class ApiAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception + ) throws IOException { + AuthErrorCode errorCode = exception instanceof CredentialAuthenticationException credentialException + ? credentialException.getErrorCode() + : AuthErrorCode.AUTH_SESSION_EXPIRED; + + response.setStatus(errorCode.getStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); + response.setHeader(HttpHeaders.PRAGMA, "no-cache"); + if (exception instanceof CredentialAuthenticationException credentialException + && credentialException.isBearer()) { + response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer"); + } + objectMapper.writeValue( + response.getWriter(), + new ErrorResponse(errorCode.getCode(), errorCode.getMessage())); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/security/AuthNoStoreFilter.java b/auth/src/main/java/com/nalssilog/auth/security/AuthNoStoreFilter.java new file mode 100644 index 0000000..c21631c --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/security/AuthNoStoreFilter.java @@ -0,0 +1,38 @@ +package com.nalssilog.auth.security; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE + 2) +public class AuthNoStoreFilter extends OncePerRequestFilter { + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); + response.setHeader(HttpHeaders.PRAGMA, "no-cache"); + response.setHeader("Referrer-Policy", "no-referrer"); + filterChain.doFilter(request, response); + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getRequestURI(); + + return !path.startsWith("/api/auth/") + && !path.startsWith("/login/oauth2/code/") + && !path.equals("/api/mobile/guests"); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/config/AuthSecurityConfig.java b/auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java similarity index 56% rename from auth/src/main/java/com/nalssilog/auth/config/AuthSecurityConfig.java rename to auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java index e2c22f8..4429ae2 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/AuthSecurityConfig.java +++ b/auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java @@ -1,7 +1,20 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.security; -import com.nalssilog.auth.application.CustomOAuth2UserService; +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.config.CorsProperties; +import com.nalssilog.auth.mobile.guest.MobileGuestCredentialFilter; +import com.nalssilog.auth.mobile.oauth.MobileOAuthAuthorizationRequestRepository; +import com.nalssilog.auth.mobile.oauth.MobileOAuthAuthorizationRequestResolver; +import com.nalssilog.auth.oauth.CustomOAuth2UserService; +import com.nalssilog.auth.oauth.CustomOidcUserService; +import com.nalssilog.auth.oauth.OAuth2LoginFailureHandler; +import com.nalssilog.auth.oauth.OAuth2LoginSuccessHandler; +import com.nalssilog.auth.oauth.apple.AppleAuthorizationResponseFilter; +import com.nalssilog.auth.token.JwtAuthenticationFilter; +import com.nalssilog.common.security.VerifiedRequestCredentials; +import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; @@ -13,10 +26,13 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.client.endpoint.OAuth2AccessTokenResponseClient; +import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; +import org.springframework.security.oauth2.client.web.OAuth2LoginAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; -import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; import org.springframework.security.web.authentication.www.BasicAuthenticationFilter; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import org.springframework.security.web.csrf.CsrfFilter; import org.springframework.security.web.csrf.InvalidCsrfTokenException; import org.springframework.security.web.csrf.MissingCsrfTokenException; import org.springframework.web.cors.CorsConfiguration; @@ -29,22 +45,31 @@ public class AuthSecurityConfig { private final CustomOAuth2UserService customOAuth2UserService; + private final CustomOidcUserService customOidcUserService; private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler; private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler; private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final MobileGuestCredentialFilter mobileGuestCredentialFilter; + private final AppleAuthorizationResponseFilter appleAuthorizationResponseFilter; + private final MobileOAuthAuthorizationRequestResolver mobileOAuthAuthorizationRequestResolver; + private final MobileOAuthAuthorizationRequestRepository mobileOAuthAuthorizationRequestRepository; + private final OAuth2AccessTokenResponseClient + authorizationCodeTokenResponseClient; + private final ApiAuthenticationEntryPoint apiAuthenticationEntryPoint; private final AuthProperties authProperties; private final CorsProperties corsProperties; @Bean - // S1130/S112: HttpSecurity.build() 가 checked Exception 을 던져 throws Exception 이 강제됨(프레임워크 API). @SuppressWarnings({"java:S1130", "java:S112"}) public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - // 쿠키 기반 인증이라 CSRF 방어 필요. SPA 더블 서브밋: XSRF-TOKEN 쿠키(JS 읽기 가능) ↔ X-XSRF-TOKEN 헤더. - // 프론트는 상태변경(POST/PUT/PATCH/DELETE) 요청에 이 헤더를 실어야 한다. (GET·OAuth 리다이렉트는 면제) .csrf(csrf -> csrf .csrfTokenRepository(csrfTokenRepository()) - .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler())) + .csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()) + .ignoringRequestMatchers( + this::isMobileCredentialEndpoint, + this::isAppleAuthorizationCallback, + VerifiedRequestCredentials::hasNonCookieCredential)) .cors(Customizer.withDefaults()) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) @@ -52,46 +77,60 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/error", "/api/health", "/oauth2/**", "/login/oauth2/**").permitAll() - // 소셜 로그인 진입·세션 조회·가입 확정·연동 흐름 (JWT 없이 티켓/쿠키로 식별) .requestMatchers("/api/auth/login/**", "/api/auth/me", "/api/auth/signup").permitAll() - // 설정에서 시작하는 소셜 추가 연동은 로그인 상태 전용 (아래 link/** permitAll 보다 먼저 매칭) + .requestMatchers( + "/api/auth/mobile/login/**", + "/api/auth/mobile/token", + "/api/auth/mobile/signup", + "/api/auth/mobile/refresh", + "/api/auth/mobile/logout", + "/api/auth/mobile/link/consent", + "/api/auth/mobile/link/cancel", + "/api/mobile/guests").permitAll() + .requestMatchers("/api/auth/mobile/link/social/**").authenticated() .requestMatchers("/api/auth/link/social/**").authenticated() .requestMatchers("/api/auth/refresh", "/api/auth/logout", "/api/auth/link/**").permitAll() - // 즐겨찾기는 회원 전용 (아래 지역 GET 허용보다 먼저 매칭돼야 함) .requestMatchers("/api/locations/favorites", "/api/locations/favorites/**").authenticated() - // 내 제보 목록은 회원 전용 (아래 report GET 허용보다 먼저 매칭돼야 함) .requestMatchers(HttpMethod.GET, "/api/reports/me").authenticated() .requestMatchers(HttpMethod.GET, "/api/locations/**", "/api/reports/**").permitAll() - // 닉네임 중복확인은 가입(비로그인) 단계에서도 써야 하므로 공개 .requestMatchers(HttpMethod.GET, "/api/members/nickname/availability").permitAll() - // 본인 계정 조회는 인증 필요 (아래 공개 프로필 permitAll 보다 먼저 매칭) .requestMatchers(HttpMethod.GET, "/api/members/me").authenticated() - // 공개 회원 프로필 조회 (단일 세그먼트만 — /me·/me/**·/nickname/** 는 인증 유지) .requestMatchers(HttpMethod.GET, "/api/members/*").permitAll() - // 서비스 피드백은 비로그인도 제출 가능 (memberId 는 있으면 기록) .requestMatchers(HttpMethod.POST, "/api/feedbacks").permitAll() - // 익명 제보·감사해요 허용 (익명 UUID 쿠키로 식별) .requestMatchers(HttpMethod.POST, "/api/reports/**").permitAll() .requestMatchers(HttpMethod.DELETE, "/api/reports/*").permitAll() .requestMatchers(HttpMethod.DELETE, "/api/reports/*/thanks").permitAll() .anyRequest().authenticated()) .oauth2Login(oauth2 -> oauth2 - .userInfoEndpoint(userInfo -> userInfo.userService(customOAuth2UserService)) + .authorizationEndpoint(endpoint -> endpoint + .authorizationRequestResolver(mobileOAuthAuthorizationRequestResolver) + .authorizationRequestRepository(mobileOAuthAuthorizationRequestRepository)) + .tokenEndpoint(endpoint -> endpoint + .accessTokenResponseClient(authorizationCodeTokenResponseClient)) + .userInfoEndpoint(userInfo -> userInfo + .userService(customOAuth2UserService) + .oidcUserService(customOidcUserService)) .successHandler(oAuth2LoginSuccessHandler) .failureHandler(oAuth2LoginFailureHandler)) .exceptionHandling(exception -> exception - .authenticationEntryPoint((request, response, authException) -> - writeError(response, HttpServletResponse.SC_UNAUTHORIZED, "AUTH_SESSION_EXPIRED", "로그인이 필요합니다.")) + .authenticationEntryPoint(apiAuthenticationEntryPoint) .accessDeniedHandler((request, response, accessDeniedException) -> { if (accessDeniedException instanceof MissingCsrfTokenException) { - writeError(response, HttpServletResponse.SC_FORBIDDEN, "CSRF_TOKEN_MISSING", "CSRF 토큰이 없습니다."); + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "CSRF_TOKEN_MISSING", "CSRF 토큰이 없습니다."); } else if (accessDeniedException instanceof InvalidCsrfTokenException) { - writeError(response, HttpServletResponse.SC_FORBIDDEN, "CSRF_TOKEN_INVALID", "CSRF 토큰이 유효하지 않습니다."); + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "CSRF_TOKEN_INVALID", "CSRF 토큰이 유효하지 않습니다."); } else { - writeError(response, HttpServletResponse.SC_FORBIDDEN, "ACCESS_DENIED", "접근 권한이 없습니다."); + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "ACCESS_DENIED", "접근 권한이 없습니다."); } })) - .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .addFilterBefore(jwtAuthenticationFilter, CsrfFilter.class) + .addFilterAfter(mobileGuestCredentialFilter, JwtAuthenticationFilter.class) + .addFilterBefore( + appleAuthorizationResponseFilter, + OAuth2LoginAuthenticationFilter.class) .addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class); return http.build(); @@ -100,22 +139,23 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - // credentials=true 라 "*" 불가. env 별 실제 프론트 origin 만 허용(패턴 지원 — *.vercel.app 등). + configuration.setAllowedOriginPatterns(corsProperties.allowedOrigins()); configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); return source; } - // S3330: 더블서브밋 CSRF 토큰은 프론트 JS 가 읽어 X-XSRF-TOKEN 헤더로 되돌려야 하므로 HttpOnly=false 가 의도된 설계. - // 인증 정보가 담긴 쿠키는 AuthCookieManager 에서 별도로 HttpOnly=true 로 생성한다. @SuppressWarnings("java:S3330") private CookieCsrfTokenRepository csrfTokenRepository() { CookieCsrfTokenRepository repository = new CookieCsrfTokenRepository(); + repository.setCookieName(authProperties.csrf().cookieName()); repository.setCookieCustomizer(cookie -> { cookie.httpOnly(false) @@ -124,6 +164,7 @@ private CookieCsrfTokenRepository csrfTokenRepository() { .path("/"); String domain = authProperties.csrf().cookieDomain(); + if (domain != null && !domain.isBlank()) { cookie.domain(domain); } @@ -132,8 +173,26 @@ private CookieCsrfTokenRepository csrfTokenRepository() { return repository; } - private void writeError(HttpServletResponse response, int status, String code, String message) - throws java.io.IOException { + private boolean isMobileCredentialEndpoint(HttpServletRequest request) { + String path = request.getRequestURI(); + + return path.startsWith("/api/auth/mobile/") + || path.equals("/api/mobile/guests"); + } + + private boolean isAppleAuthorizationCallback(HttpServletRequest request) { + + return HttpMethod.POST.matches(request.getMethod()) + && AppleAuthorizationResponseFilter.CALLBACK_PATH.equals( + request.getRequestURI()); + } + + private void writeError( + HttpServletResponse response, + int status, + String code, + String message + ) throws IOException { response.setStatus(status); response.setContentType(MediaType.APPLICATION_JSON_VALUE); response.setCharacterEncoding("UTF-8"); diff --git a/auth/src/main/java/com/nalssilog/auth/security/CredentialAuthenticationException.java b/auth/src/main/java/com/nalssilog/auth/security/CredentialAuthenticationException.java new file mode 100644 index 0000000..dcab701 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/security/CredentialAuthenticationException.java @@ -0,0 +1,25 @@ +package com.nalssilog.auth.security; + +import com.nalssilog.auth.core.AuthErrorCode; +import lombok.Getter; +import org.springframework.security.core.AuthenticationException; + +@Getter +public class CredentialAuthenticationException extends AuthenticationException { + + private final AuthErrorCode errorCode; + private final boolean bearer; + + public CredentialAuthenticationException(AuthErrorCode errorCode) { + this(errorCode, true); + } + + public CredentialAuthenticationException( + AuthErrorCode errorCode, + boolean bearer + ) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + this.bearer = bearer; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/config/CsrfCookieFilter.java b/auth/src/main/java/com/nalssilog/auth/security/CsrfCookieFilter.java similarity index 96% rename from auth/src/main/java/com/nalssilog/auth/config/CsrfCookieFilter.java rename to auth/src/main/java/com/nalssilog/auth/security/CsrfCookieFilter.java index 02ac59e..896c733 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/CsrfCookieFilter.java +++ b/auth/src/main/java/com/nalssilog/auth/security/CsrfCookieFilter.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.security; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; diff --git a/auth/src/main/java/com/nalssilog/auth/config/SpaCsrfTokenRequestHandler.java b/auth/src/main/java/com/nalssilog/auth/security/SpaCsrfTokenRequestHandler.java similarity index 97% rename from auth/src/main/java/com/nalssilog/auth/config/SpaCsrfTokenRequestHandler.java rename to auth/src/main/java/com/nalssilog/auth/security/SpaCsrfTokenRequestHandler.java index c2b7272..06f4643 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/SpaCsrfTokenRequestHandler.java +++ b/auth/src/main/java/com/nalssilog/auth/security/SpaCsrfTokenRequestHandler.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.security; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; diff --git a/auth/src/main/java/com/nalssilog/auth/ticket/AuthChannel.java b/auth/src/main/java/com/nalssilog/auth/ticket/AuthChannel.java new file mode 100644 index 0000000..fed2c2f --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/ticket/AuthChannel.java @@ -0,0 +1,6 @@ +package com.nalssilog.auth.ticket; + +public enum AuthChannel { + WEB, + MOBILE +} diff --git a/auth/src/main/java/com/nalssilog/auth/ticket/AuthTicketStore.java b/auth/src/main/java/com/nalssilog/auth/ticket/AuthTicketStore.java new file mode 100644 index 0000000..8f29025 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/ticket/AuthTicketStore.java @@ -0,0 +1,235 @@ +package com.nalssilog.auth.ticket; + +import com.nalssilog.member.domain.Provider; +import java.time.Duration; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.DefaultRedisScript; +import org.springframework.stereotype.Repository; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.ObjectMapper; + +/** 확정 전 상태(가입 대기/연동 대기)를 잠깐 들고 있는 티켓 저장소 (Redis, 단기 TTL, JSON 직렬화). */ +@Repository +@RequiredArgsConstructor +public class AuthTicketStore { + + private static final String SIGNUP_PREFIX = "auth:ticket:signup:"; + private static final String LINK_PREFIX = "auth:ticket:link:"; + private static final String LINK_CONSENT_PREFIX = "auth:ticket:link:consent:"; + private static final String LINK_INTENT_PREFIX = "auth:ticket:link-intent:"; + private static final String SIGNUP_LOCK_PREFIX = "auth:ticket:signup-lock:"; + private static final String SIGNUP_COMPLETED_PREFIX = "auth:ticket:signup-completed:"; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + + private static final DefaultRedisScript COMPLETE_SIGNUP_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('GET', KEYS[1]) ~= ARGV[1] then + return 0 + end + redis.call('SET', KEYS[2], ARGV[2], 'PX', ARGV[3]) + redis.call('DEL', KEYS[3]) + redis.call('DEL', KEYS[1]) + return 1 + """, Long.class); + + private static final DefaultRedisScript RELEASE_LOCK_SCRIPT = new DefaultRedisScript<>(""" + if redis.call('GET', KEYS[1]) == ARGV[1] then + return redis.call('DEL', KEYS[1]) + end + return 0 + """, Long.class); + + private final StringRedisTemplate redisTemplate; + + public void saveSignup(String ticketId, SignupTicket ticket, Duration ttl) { + redisTemplate.opsForValue().set(SIGNUP_PREFIX + ticketId, serialize(ticket), ttl); + } + + public Optional findSignup(String ticketId) { + + return read(SIGNUP_PREFIX + ticketId, SignupTicket.class); + } + + public void deleteSignup(String ticketId) { + redisTemplate.delete(SIGNUP_PREFIX + ticketId); + } + + public SignupClaim claimSignup(String ticketId, String claimId, Duration ttl) { + Optional completed = findSignupCompletion(ticketId); + + if (completed.isPresent()) { + + return SignupClaim.completed(completed.get()); + } + + Boolean acquired = redisTemplate.opsForValue().setIfAbsent( + SIGNUP_LOCK_PREFIX + ticketId, + claimId, + ttl); + + if (!Boolean.TRUE.equals(acquired)) { + + return findSignupCompletion(ticketId) + .map(SignupClaim::completed) + .orElseGet(SignupClaim::inProgress); + } + + Optional ticket = findSignup(ticketId); + + if (ticket.isEmpty()) { + releaseSignupClaim(ticketId, claimId); + + return findSignupCompletion(ticketId) + .map(SignupClaim::completed) + .orElseGet(SignupClaim::missing); + } + + return SignupClaim.claimed(ticket.get()); + } + + public void completeSignup( + String ticketId, + String claimId, + SignupCompletion completion, + Duration ttl + ) { + Long completed = redisTemplate.execute( + COMPLETE_SIGNUP_SCRIPT, + List.of( + SIGNUP_LOCK_PREFIX + ticketId, + SIGNUP_COMPLETED_PREFIX + ticketId, + SIGNUP_PREFIX + ticketId), + claimId, + serialize(completion), + String.valueOf(ttl.toMillis())); + + if (completed == null || completed != 1L) { + throw new IllegalStateException("signup ticket claim was lost"); + } + } + + public void releaseSignupClaim(String ticketId, String claimId) { + redisTemplate.execute( + RELEASE_LOCK_SCRIPT, + List.of(SIGNUP_LOCK_PREFIX + ticketId), + claimId); + } + + public Optional findSignupCompletion(String ticketId) { + + return read(SIGNUP_COMPLETED_PREFIX + ticketId, SignupCompletion.class); + } + + public void saveLink(String ticketId, LinkTicket ticket, Duration ttl) { + redisTemplate.opsForValue().set(LINK_PREFIX + ticketId, serialize(ticket), ttl); + } + + public Optional findLink(String ticketId) { + + return read(LINK_PREFIX + ticketId, LinkTicket.class); + } + + public void deleteLink(String ticketId) { + redisTemplate.delete(LINK_PREFIX + ticketId); + } + + /** '연동' 명시 동의 플래그. 이게 있어야만 재인증 후 실제 연동됨(방치 티켓 자동연동 방지). */ + public void markLinkConsented(String ticketId, Duration ttl) { + redisTemplate.opsForValue().set(LINK_CONSENT_PREFIX + ticketId, "1", ttl); + } + + public boolean isLinkConsented(String ticketId) { + + return Boolean.TRUE.equals(redisTemplate.hasKey(LINK_CONSENT_PREFIX + ticketId)); + } + + public void deleteLinkConsent(String ticketId) { + redisTemplate.delete(LINK_CONSENT_PREFIX + ticketId); + } + + /** 설정에서 시작한 '소셜 추가 연동' 의도(memberId 를 잠깐 보관, 성공 핸들러가 연동에 사용). */ + public void saveLinkIntent(String intentId, Long memberId, Duration ttl) { + redisTemplate.opsForValue().set(LINK_INTENT_PREFIX + intentId, String.valueOf(memberId), ttl); + } + + public Optional findLinkIntent(String intentId) { + String value = redisTemplate.opsForValue().get(LINK_INTENT_PREFIX + intentId); + + return Optional.ofNullable(value).map(Long::valueOf); + } + + public void deleteLinkIntent(String intentId) { + redisTemplate.delete(LINK_INTENT_PREFIX + intentId); + } + + private Optional read(String key, Class type) { + String value = redisTemplate.opsForValue().get(key); + + if (value == null) { + + return Optional.empty(); + } + + try { + + return Optional.of(OBJECT_MAPPER.readValue(value, type)); + } catch (JacksonException e) { + throw new IllegalStateException("인증 티켓 역직렬화 실패", e); + } + } + + private String serialize(Object ticket) { + try { + + return OBJECT_MAPPER.writeValueAsString(ticket); + } catch (JacksonException e) { + throw new IllegalStateException("인증 티켓 직렬화 실패", e); + } + } + + public enum SignupClaimStatus { + CLAIMED, + COMPLETED, + IN_PROGRESS, + MISSING + } + + public record SignupCompletion( + Long memberId, + Provider provider, + String accessToken, + String refreshToken, + long refreshTokenMaxAgeMillis, + long completedAtEpochMillis + ) { + } + + public record SignupClaim( + SignupClaimStatus status, + SignupTicket ticket, + SignupCompletion completion + ) { + + private static SignupClaim claimed(SignupTicket ticket) { + + return new SignupClaim(SignupClaimStatus.CLAIMED, ticket, null); + } + + private static SignupClaim completed(SignupCompletion completion) { + + return new SignupClaim(SignupClaimStatus.COMPLETED, null, completion); + } + + private static SignupClaim inProgress() { + + return new SignupClaim(SignupClaimStatus.IN_PROGRESS, null, null); + } + + private static SignupClaim missing() { + + return new SignupClaim(SignupClaimStatus.MISSING, null, null); + } + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/domain/LinkTicket.java b/auth/src/main/java/com/nalssilog/auth/ticket/LinkTicket.java similarity index 55% rename from auth/src/main/java/com/nalssilog/auth/domain/LinkTicket.java rename to auth/src/main/java/com/nalssilog/auth/ticket/LinkTicket.java index 264c781..8e9acbc 100644 --- a/auth/src/main/java/com/nalssilog/auth/domain/LinkTicket.java +++ b/auth/src/main/java/com/nalssilog/auth/ticket/LinkTicket.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.domain; +package com.nalssilog.auth.ticket; import com.nalssilog.member.domain.Provider; import java.util.List; @@ -13,6 +13,22 @@ public record LinkTicket( String providerUserId, String email, Long targetMemberId, - List existingProviders + List existingProviders, + AuthChannel channel ) { + + public LinkTicket( + Provider provider, + String providerUserId, + String email, + Long targetMemberId, + List existingProviders + ) { + this(provider, providerUserId, email, targetMemberId, existingProviders, AuthChannel.WEB); + } + + public AuthChannel effectiveChannel() { + + return channel == null ? AuthChannel.WEB : channel; + } } diff --git a/auth/src/main/java/com/nalssilog/auth/ticket/SignupTicket.java b/auth/src/main/java/com/nalssilog/auth/ticket/SignupTicket.java new file mode 100644 index 0000000..9baf605 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/ticket/SignupTicket.java @@ -0,0 +1,30 @@ +package com.nalssilog.auth.ticket; + +import com.fasterxml.jackson.annotation.JsonAlias; +import com.nalssilog.member.domain.Provider; + +/** + * 소셜 인증은 끝났지만 아직 가입을 확정하지 않은 신규 사용자의 임시 상태. (Redis 단기 보관) + */ +public record SignupTicket( + Provider provider, + String providerUserId, + String email, + @JsonAlias("nickname") String socialName, + AuthChannel channel +) { + + public SignupTicket( + Provider provider, + String providerUserId, + String email, + String socialName + ) { + this(provider, providerUserId, email, socialName, AuthChannel.WEB); + } + + public AuthChannel effectiveChannel() { + + return channel == null ? AuthChannel.WEB : channel; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/token/AuthRequestDetails.java b/auth/src/main/java/com/nalssilog/auth/token/AuthRequestDetails.java new file mode 100644 index 0000000..b62fa4a --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/token/AuthRequestDetails.java @@ -0,0 +1,10 @@ +package com.nalssilog.auth.token; + +import com.nalssilog.member.domain.Provider; + +public record AuthRequestDetails( + Provider provider, + String sessionId, + CredentialTransport transport +) { +} diff --git a/auth/src/main/java/com/nalssilog/auth/application/AuthSessionService.java b/auth/src/main/java/com/nalssilog/auth/token/AuthSessionService.java similarity index 75% rename from auth/src/main/java/com/nalssilog/auth/application/AuthSessionService.java rename to auth/src/main/java/com/nalssilog/auth/token/AuthSessionService.java index c4b8c63..0b0c599 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/AuthSessionService.java +++ b/auth/src/main/java/com/nalssilog/auth/token/AuthSessionService.java @@ -1,10 +1,7 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.token; -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.auth.core.AuthErrorCode; import com.nalssilog.common.exception.NalssiLogException; import java.util.Comparator; import java.util.List; @@ -22,7 +19,8 @@ public class AuthSessionService { private final RefreshTokenStore refreshTokenStore; private final AuthProperties properties; - public List listSessions(Long memberId, String currentTokenHash) { + public List listSessions(Long memberId, String currentSessionId) { + return refreshTokenStore.findSessionsByMember(memberId).stream() .sorted(Comparator.comparing(SessionData::lastActiveAt).reversed()) .map(session -> new SessionView( @@ -31,23 +29,22 @@ public List listSessions(Long memberId, String currentTokenHash) { session.ip(), session.loginAt(), session.lastActiveAt(), - session.tokenHash().equals(currentTokenHash))) + session.sessionId().equals(currentSessionId))) .toList(); } /** * 특정 세션(기기) 로그아웃. 대상이 현재 세션이면 true 를 반환해 컨트롤러가 쿠키까지 정리하게 한다. */ - public boolean revokeSession(Long memberId, String sessionId, String currentTokenHash) { + public boolean revokeSession(Long memberId, String sessionId, String currentSessionId) { List sessions = refreshTokenStore.findSessionsByMember(memberId); SessionData target = sessions.stream() .filter(session -> session.sessionId().equals(sessionId)) .findFirst() .orElseThrow(() -> new NalssiLogException(AuthErrorCode.SESSION_NOT_FOUND)); - boolean current = currentTokenHash != null && sessions.stream() - .anyMatch(session -> session.sessionId().equals(sessionId) - && session.tokenHash().equals(currentTokenHash)); + boolean current = currentSessionId != null && sessionId.equals(currentSessionId); + refreshTokenStore.revokeSession(memberId, target.sessionId(), properties.jwt().refreshTokenTtl()); return current; diff --git a/auth/src/main/java/com/nalssilog/auth/application/AuthTokenService.java b/auth/src/main/java/com/nalssilog/auth/token/AuthTokenService.java similarity index 90% rename from auth/src/main/java/com/nalssilog/auth/application/AuthTokenService.java rename to auth/src/main/java/com/nalssilog/auth/token/AuthTokenService.java index a63e346..ff347c6 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/AuthTokenService.java +++ b/auth/src/main/java/com/nalssilog/auth/token/AuthTokenService.java @@ -1,13 +1,11 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.token; -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.core.AuthErrorCode; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.token.RefreshTokenStore.RotationResult; +import com.nalssilog.auth.token.RefreshTokenStore.RotationStatus; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.domain.MemberStatus; @@ -43,7 +41,9 @@ public class AuthTokenService { public TokenPair issue(Long memberId, MemberStatus status, Provider provider, DeviceInfo 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; } @@ -54,7 +54,9 @@ public TokenPair issue(Long memberId, MemberStatus status, Provider provider, De public TokenPair refresh(String refreshToken, DeviceInfo device) { String currentHash = hash(refreshToken); Optional current = refreshTokenStore.findSession(currentHash); + if (current.isEmpty()) { + return resolveInactiveRefresh(currentHash); } @@ -83,7 +85,11 @@ public TokenPair refresh(String refreshToken, DeviceInfo device) { log.info("auth.refresh.rotated memberId={} sessionId={} token={}", member.id(), currentSession.sessionId(), fingerprint(currentHash)); yield tokenPair( - member, currentSession.provider(), replacementToken, properties.jwt().refreshTokenTtl()); + member, + currentSession.provider(), + currentSession.sessionId(), + replacementToken, + properties.jwt().refreshTokenTtl()); } case RETRIED -> retry(result, currentHash); case REUSED -> rejectReuse(result, currentHash); @@ -93,36 +99,46 @@ yield tokenPair( public void revoke(String 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))); + () -> log.info( + "auth.refresh.revoke_skipped reason=not_found token={}", + fingerprint(tokenHash))); } /** refresh 토큰의 세션 키(해시). '현재 세션' 판별용. */ public String tokenHash(String refreshToken) { + return hash(refreshToken); } /** 해당 회원의 모든 refresh 세션 강제 만료(전 기기). 탈퇴 시 사용. */ public void revokeAllSessions(Long 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), @@ -149,15 +165,18 @@ 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(), + replacement.sessionId(), result.replacementToken(), Duration.ofMillis(result.refreshTokenTtlMillis())); } @@ -166,6 +185,7 @@ 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 { @@ -182,12 +202,15 @@ private TokenPair rejectExpired(String currentHash, String 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"); }); } @@ -199,14 +222,16 @@ private TokenPair issueWithSession(Long memberId, MemberStatus status, Provider SessionData session = sessionData(tokenHash, memberId, provider, sessionId, loginAt, device); refreshTokenStore.save(tokenHash, session, properties.jwt().refreshTokenTtl()); + return new TokenPair( - jwtTokenProvider.createAccessToken(memberId, status, provider), + jwtTokenProvider.createAccessToken(memberId, status, provider, sessionId), refreshToken, properties.jwt().refreshTokenTtl()); } private SessionData sessionData(String tokenHash, Long memberId, Provider provider, String sessionId, Instant loginAt, DeviceInfo device) { + return new SessionData( tokenHash, sessionId, @@ -218,26 +243,37 @@ private SessionData sessionData(String tokenHash, Long memberId, Provider provid Instant.now()); } - private TokenPair tokenPair(MemberInfo member, Provider provider, String refreshToken, Duration maxAge) { + private TokenPair tokenPair( + MemberInfo member, + Provider provider, + String sessionId, + String refreshToken, + Duration maxAge + ) { + return new TokenPair( - jwtTokenProvider.createAccessToken(member.id(), member.status(), provider), + jwtTokenProvider.createAccessToken(member.id(), member.status(), provider, sessionId), 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/token/CredentialTransport.java b/auth/src/main/java/com/nalssilog/auth/token/CredentialTransport.java new file mode 100644 index 0000000..773f132 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/token/CredentialTransport.java @@ -0,0 +1,6 @@ +package com.nalssilog.auth.token; + +public enum CredentialTransport { + COOKIE, + BEARER +} diff --git a/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java b/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java new file mode 100644 index 0000000..fa2f4f9 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java @@ -0,0 +1,164 @@ +package com.nalssilog.auth.token; + +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.mobile.guest.MobileGuestCredentialService; +import com.nalssilog.auth.oauth.SocialAuthPrincipal; +import com.nalssilog.auth.security.ApiAuthenticationEntryPoint; +import com.nalssilog.auth.security.CredentialAuthenticationException; +import com.nalssilog.auth.token.JwtTokenProvider.AccessTokenPayload; +import com.nalssilog.auth.token.JwtTokenProvider.TokenValidation; +import com.nalssilog.auth.token.JwtTokenProvider.TokenValidationStatus; +import com.nalssilog.auth.web.AuthCookieManager; +import com.nalssilog.common.filter.RequestLoggingFilter; +import com.nalssilog.common.security.VerifiedRequestCredentials; +import com.nalssilog.member.domain.MemberStatus; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Collections; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private static final Pattern BEARER_PATTERN = Pattern.compile( + "^Bearer ([A-Za-z0-9\\-._~+/]+=*)$", + Pattern.CASE_INSENSITIVE); + + private final AuthCookieManager cookieManager; + private final JwtTokenProvider jwtTokenProvider; + private final RefreshTokenStore refreshTokenStore; + private final ApiAuthenticationEntryPoint authenticationEntryPoint; + + @Override + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { + List authorizationHeaders = + Collections.list(request.getHeaders(HttpHeaders.AUTHORIZATION)); + + if (!authorizationHeaders.isEmpty()) { + authenticateBearer(request, response, authorizationHeaders); + if (response.isCommitted()) { + + return; + } + } else if (allowsCookieFallback(request)) { + authenticateCookie(request); + } + + filterChain.doFilter(request, response); + } + + private void authenticateBearer( + HttpServletRequest request, + HttpServletResponse response, + List values + ) throws IOException { + if (values.size() != 1 || values.getFirst() == null || values.getFirst().contains(",")) { + reject(request, response, AuthErrorCode.AUTH_ACCESS_TOKEN_INVALID); + + return; + } + + Matcher matcher = BEARER_PATTERN.matcher(values.getFirst()); + + if (!matcher.matches()) { + reject(request, response, AuthErrorCode.AUTH_ACCESS_TOKEN_INVALID); + + return; + } + + TokenValidation validation = jwtTokenProvider.validate(matcher.group(1)); + + if (validation.status() == TokenValidationStatus.EXPIRED) { + reject(request, response, AuthErrorCode.AUTH_ACCESS_TOKEN_EXPIRED); + + return; + } + if (validation.status() != TokenValidationStatus.VALID + || !isUsable(validation.payload())) { + reject(request, response, AuthErrorCode.AUTH_ACCESS_TOKEN_INVALID); + + return; + } + if (isRevoked(validation.payload())) { + reject(request, response, AuthErrorCode.AUTH_SESSION_EXPIRED); + + return; + } + + setAuthentication(request, validation.payload(), CredentialTransport.BEARER); + VerifiedRequestCredentials.markBearer(request); + } + + private void authenticateCookie(HttpServletRequest request) { + cookieManager.readAccessToken(request) + .flatMap(jwtTokenProvider::parse) + .filter(this::isUsable) + .filter(payload -> !isRevoked(payload)) + .ifPresent(payload -> setAuthentication(request, payload, CredentialTransport.COOKIE)); + } + + private boolean isUsable(AccessTokenPayload payload) { + + return payload != null && payload.status() != MemberStatus.WITHDRAWN; + } + + private boolean isRevoked(AccessTokenPayload payload) { + + return payload.sessionId() != null + && !payload.sessionId().isBlank() + && refreshTokenStore.isSessionRevoked(payload.sessionId()); + } + + private void setAuthentication( + HttpServletRequest request, + AccessTokenPayload payload, + CredentialTransport transport + ) { + var authentication = UsernamePasswordAuthenticationToken.authenticated( + payload.memberId(), + null, + List.of(new SimpleGrantedAuthority(SocialAuthPrincipal.roleOf(payload.status())))); + + authentication.setDetails(new AuthRequestDetails( + payload.provider(), payload.sessionId(), transport)); + SecurityContextHolder.getContext().setAuthentication(authentication); + request.setAttribute(RequestLoggingFilter.ACTOR_MEMBER_ID, payload.memberId()); + } + + private void reject( + HttpServletRequest request, + HttpServletResponse response, + AuthErrorCode errorCode + ) throws IOException { + SecurityContextHolder.clearContext(); + authenticationEntryPoint.commence( + request, + response, + new CredentialAuthenticationException(errorCode)); + } + + private boolean allowsCookieFallback(HttpServletRequest request) { + String path = request.getRequestURI(); + + return !path.startsWith("/api/auth/mobile/") + && !path.equals("/api/mobile/guests") + && request.getHeader(MobileGuestCredentialService.HEADER) == null; + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/application/JwtTokenProvider.java b/auth/src/main/java/com/nalssilog/auth/token/JwtTokenProvider.java similarity index 51% rename from auth/src/main/java/com/nalssilog/auth/application/JwtTokenProvider.java rename to auth/src/main/java/com/nalssilog/auth/token/JwtTokenProvider.java index fc747d5..94f7a21 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/JwtTokenProvider.java +++ b/auth/src/main/java/com/nalssilog/auth/token/JwtTokenProvider.java @@ -1,9 +1,10 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.token; import com.nalssilog.auth.config.AuthProperties; import com.nalssilog.member.domain.MemberStatus; import com.nalssilog.member.domain.Provider; import io.jsonwebtoken.Claims; +import io.jsonwebtoken.ExpiredJwtException; import io.jsonwebtoken.JwtException; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; @@ -27,6 +28,7 @@ public class JwtTokenProvider { public JwtTokenProvider(AuthProperties properties) { String secret = properties.jwt().secret(); + if (secret == null || secret.isBlank()) { log.warn("JWT secret 이 설정되지 않아 임시 키를 생성합니다. 재시작하면 모든 액세스 토큰이 무효화됩니다."); this.key = Jwts.SIG.HS256.key().build(); @@ -37,19 +39,44 @@ public JwtTokenProvider(AuthProperties properties) { } public String createAccessToken(Long memberId, MemberStatus status, Provider provider) { + + return createAccessToken(memberId, status, provider, null); + } + + public String createAccessToken( + Long memberId, + MemberStatus status, + Provider provider, + String sessionId + ) { Instant now = Instant.now(); - return Jwts.builder() + var builder = Jwts.builder() .subject(String.valueOf(memberId)) .claim("status", status.name()) .claim("provider", provider.name()) + .claim("token_type", "access") .issuedAt(Date.from(now)) - .expiration(Date.from(now.plus(accessTokenTtl))) + .expiration(Date.from(now.plus(accessTokenTtl))); + + if (sessionId != null && !sessionId.isBlank()) { + builder.claim("sid", sessionId); + } + + return builder .signWith(key) .compact(); } public Optional parse(String token) { + TokenValidation validation = validate(token); + + return validation.status() == TokenValidationStatus.VALID + ? Optional.of(validation.payload()) + : Optional.empty(); + } + + public TokenValidation validate(String token) { try { Claims claims = Jwts.parser() .verifyWith(key) @@ -58,21 +85,62 @@ public Optional parse(String token) { .getPayload(); String status = claims.get("status", String.class); String provider = claims.get("provider", String.class); + String tokenType = claims.get("token_type", String.class); + + if (status == null || provider == null + || (tokenType != null && !"access".equals(tokenType))) { - if (status == null || provider == null) { - return Optional.empty(); + return TokenValidation.invalid(); } - return Optional.of(new AccessTokenPayload( + return TokenValidation.valid(new AccessTokenPayload( Long.parseLong(claims.getSubject()), MemberStatus.valueOf(status), - Provider.valueOf(provider) + Provider.valueOf(provider), + claims.get("sid", String.class) )); + } catch (ExpiredJwtException _) { + + return TokenValidation.expired(); } catch (JwtException | IllegalArgumentException _) { - return Optional.empty(); + + return TokenValidation.invalid(); + } + } + + public enum TokenValidationStatus { + VALID, + EXPIRED, + INVALID + } + + public record TokenValidation(TokenValidationStatus status, AccessTokenPayload payload) { + + private static TokenValidation valid(AccessTokenPayload payload) { + + return new TokenValidation(TokenValidationStatus.VALID, payload); + } + + private static TokenValidation expired() { + + return new TokenValidation(TokenValidationStatus.EXPIRED, null); + } + + private static TokenValidation invalid() { + + return new TokenValidation(TokenValidationStatus.INVALID, null); } } - public record AccessTokenPayload(Long memberId, MemberStatus status, Provider provider) { + public record AccessTokenPayload( + Long memberId, + MemberStatus status, + Provider provider, + String sessionId + ) { + + public AccessTokenPayload(Long memberId, MemberStatus status, Provider provider) { + this(memberId, status, provider, null); + } } } diff --git a/auth/src/main/java/com/nalssilog/auth/repository/RefreshTokenStore.java b/auth/src/main/java/com/nalssilog/auth/token/RefreshTokenStore.java similarity index 99% rename from auth/src/main/java/com/nalssilog/auth/repository/RefreshTokenStore.java rename to auth/src/main/java/com/nalssilog/auth/token/RefreshTokenStore.java index 0aac44c..351c813 100644 --- a/auth/src/main/java/com/nalssilog/auth/repository/RefreshTokenStore.java +++ b/auth/src/main/java/com/nalssilog/auth/token/RefreshTokenStore.java @@ -1,6 +1,5 @@ -package com.nalssilog.auth.repository; +package com.nalssilog.auth.token; -import com.nalssilog.auth.application.dto.SessionData; import com.nalssilog.member.domain.Provider; import java.time.Duration; import java.time.Instant; @@ -155,10 +154,12 @@ public void save(String tokenHash, SessionData session, Duration ttl) { FIELD_LAST_ACTIVE_AT, String.valueOf(session.lastActiveAt().toEpochMilli())); String key = key(tokenHash); + redisTemplate.opsForHash().putAll(key, fields); redisTemplate.expire(key, ttl); String memberKey = memberKey(session.memberId()); + redisTemplate.opsForSet().add(memberKey, tokenHash); redisTemplate.expire(memberKey, ttl); } @@ -197,35 +198,43 @@ public Optional findSession(String tokenHash) { Map raw = redisTemplate.opsForHash().entries(key(tokenHash)); if (raw.isEmpty()) { + return Optional.empty(); } try { + return Optional.of(toSessionData(tokenHash, raw)); } catch (IllegalArgumentException _) { // provider 필드가 없던 구버전 세션은 실제 인증 수단을 보장할 수 없으므로 재로그인시킨다. + return Optional.empty(); } } 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))); } @@ -240,6 +249,7 @@ public Optional revokeByTokenHash(String tokenHash, Duration markerT .map(used -> new SessionRef(used.memberId(), used.sessionId()))); target.ifPresent(session -> revokeSession(session.memberId(), session.sessionId(), markerTtl)); + return target; } @@ -251,6 +261,7 @@ public long revokeSession(Long memberId, String sessionId, Duration markerTtl) { sessionId, String.valueOf(markerTtl.toMillis()), KEY_PREFIX); + return deleted == null ? 0 : deleted; } @@ -265,6 +276,7 @@ public long deleteAllByMember(Long memberId, Duration markerTtl) { deleted += revokeSession(memberId, sessionId, markerTtl); } redisTemplate.delete(memberKey(memberId)); + return deleted; } @@ -273,6 +285,7 @@ public List findSessionsByMember(Long memberId) { Set hashes = redisTemplate.opsForSet().members(memberKey(memberId)); if (hashes == null || hashes.isEmpty()) { + return List.of(); } @@ -287,13 +300,16 @@ public List findSessionsByMember(Long memberId) { 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(); } @@ -307,6 +323,7 @@ private RotationResult toRotationResult(List raw) { } private SessionData toSessionData(String tokenHash, Map raw) { + return new SessionData( tokenHash, str(raw, FIELD_SESSION_ID), @@ -320,43 +337,53 @@ 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; } 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; } @@ -377,6 +404,7 @@ public record RotationResult( long refreshTokenTtlMillis ) { private static RotationResult missing() { + return new RotationResult(RotationStatus.MISSING, "", "", null, "", 0); } } diff --git a/auth/src/main/java/com/nalssilog/auth/application/dto/SessionData.java b/auth/src/main/java/com/nalssilog/auth/token/SessionData.java similarity index 92% rename from auth/src/main/java/com/nalssilog/auth/application/dto/SessionData.java rename to auth/src/main/java/com/nalssilog/auth/token/SessionData.java index 7adbbfc..b7f0a9f 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/dto/SessionData.java +++ b/auth/src/main/java/com/nalssilog/auth/token/SessionData.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application.dto; +package com.nalssilog.auth.token; import com.nalssilog.member.domain.Provider; import java.time.Instant; diff --git a/auth/src/main/java/com/nalssilog/auth/application/dto/SessionView.java b/auth/src/main/java/com/nalssilog/auth/token/SessionView.java similarity index 88% rename from auth/src/main/java/com/nalssilog/auth/application/dto/SessionView.java rename to auth/src/main/java/com/nalssilog/auth/token/SessionView.java index 66f64f6..2846d3e 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/dto/SessionView.java +++ b/auth/src/main/java/com/nalssilog/auth/token/SessionView.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application.dto; +package com.nalssilog.auth.token; import java.time.Instant; diff --git a/auth/src/main/java/com/nalssilog/auth/application/TokenPair.java b/auth/src/main/java/com/nalssilog/auth/token/TokenPair.java similarity index 85% rename from auth/src/main/java/com/nalssilog/auth/application/TokenPair.java rename to auth/src/main/java/com/nalssilog/auth/token/TokenPair.java index 86fd45a..e86094e 100644 --- a/auth/src/main/java/com/nalssilog/auth/application/TokenPair.java +++ b/auth/src/main/java/com/nalssilog/auth/token/TokenPair.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.token; import java.time.Duration; diff --git a/auth/src/main/java/com/nalssilog/auth/web/AuthController.java b/auth/src/main/java/com/nalssilog/auth/web/AuthController.java new file mode 100644 index 0000000..3de3980 --- /dev/null +++ b/auth/src/main/java/com/nalssilog/auth/web/AuthController.java @@ -0,0 +1,178 @@ +package com.nalssilog.auth.web; + +import com.nalssilog.auth.core.AuthService.SignupResult; +import com.nalssilog.auth.core.AuthService.SocialLinkStart; +import com.nalssilog.auth.core.AuthService; +import com.nalssilog.auth.device.DeviceInfoResolver; +import com.nalssilog.auth.token.TokenPair; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import java.io.IOException; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.annotation.CurrentSecurityContext; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final AuthCookieManager cookieManager; + private final DeviceInfoResolver deviceInfoResolver; + + /** 소셜 로그인 진입. 내부 Spring OAuth 경로를 은닉하고 302 시킨다. */ + @GetMapping("/login/{provider}") + public void login( + @PathVariable String provider, + HttpServletResponse response + ) throws IOException { + response.sendRedirect(authService.oauthAuthorizationUrl(provider)); + } + + /** 인증 상태 조회(stateless — AT/티켓 쿠키로 판단). */ + @GetMapping("/me") + public MeResponse me( + @AuthenticationPrincipal Long memberId, + HttpServletRequest request + ) { + boolean hasAuthenticationCookie = cookieManager.readAccessToken(request).isPresent() + || cookieManager.readRefreshToken(request).isPresent(); + + return MeResponse.from(authService.me( + memberId, + cookieManager.readSignupTicket(request), + cookieManager.readLinkTicket(request), + hasAuthenticationCookie)); + } + + /** 회원가입 확정. OAuth 정보는 signup 티켓에서 읽고, Member 는 여기서 처음 생성된다. */ + @PostMapping("/signup") + public MeResponse signup( + @Valid @RequestBody SignupRequest request, + HttpServletRequest httpRequest, + HttpServletResponse response + ) { + SignupResult result = authService.signup(cookieManager.readSignupTicket(httpRequest).orElse(null), + request.agreedTerms(), deviceInfoResolver.resolve(httpRequest)); + TokenPair tokens = result.tokens(); + + cookieManager.addAuthCookies(response, tokens.accessToken(), tokens.refreshToken(), + tokens.refreshTokenMaxAge()); + cookieManager.clearSignupTicketCookie(response); + + return MeResponse.authenticated(result.member()); + } + + @PostMapping("/refresh") + public void refresh( + HttpServletRequest request, + HttpServletResponse response + ) { + TokenPair tokens = authService.refresh( + cookieManager.readRefreshToken(request).orElse(null), + deviceInfoResolver.resolve(request)); + + cookieManager.addAuthCookies( + response, tokens.accessToken(), tokens.refreshToken(), tokens.refreshTokenMaxAge()); + } + + @PostMapping("/logout") + public void logout( + HttpServletRequest request, + HttpServletResponse response + ) { + authService.logout(cookieManager.readRefreshToken(request)); + cookieManager.clearAuthCookies(response); + } + + /** 회원 탈퇴: 익명화(member) + 제보 익명화(event) + 전 기기 세션 만료 + 쿠키 정리. 세션·쿠키 때문에 auth 소유. */ + @DeleteMapping("/withdraw") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void withdraw( + @AuthenticationPrincipal Long memberId, + HttpServletResponse response + ) { + authService.withdraw(memberId); + cookieManager.clearAuthCookies(response); + } + + /** 로그인된 기기 목록(current=이 기기). */ + @GetMapping("/sessions") + public List sessions( + @AuthenticationPrincipal Long memberId, + @CurrentSecurityContext(expression = "authentication.details.sessionId") String currentSessionId + ) { + + return authService.sessions(memberId, currentSessionId).stream() + .map(SessionResponse::from) + .toList(); + } + + /** 특정 기기 로그아웃. 대상이 현재 기기면 인증 쿠키까지 정리한다. */ + @DeleteMapping("/sessions/{sessionId}") + @ResponseStatus(HttpStatus.NO_CONTENT) + public void revokeSession( + @AuthenticationPrincipal Long memberId, + @CurrentSecurityContext(expression = "authentication.details.sessionId") String currentSessionId, + @PathVariable String sessionId, + HttpServletResponse response + ) { + boolean revokedCurrent = authService.revokeSession( + memberId, sessionId, currentSessionId); + + if (revokedCurrent) { + cookieManager.clearAuthCookies(response); + } + } + + /** 설정에서 소셜 추가 연동 시작. intent 세팅 후 OAuth 진입 URL 반환(실제 연동은 성공 핸들러). */ + @PostMapping("/link/social/{provider}") + public LinkConsentResponse startSocialLink( + @AuthenticationPrincipal Long memberId, + @PathVariable String provider, + HttpServletResponse response + ) { + SocialLinkStart link = authService.startSocialLink(memberId, provider); + + cookieManager.addLinkIntentCookie(response, link.intentId()); + + return new LinkConsentResponse(link.authorizationUrl()); + } + + /** 로그인-시점 연동 동의 + 기존 수단 재인증 URL 반환. */ + @PostMapping("/link/consent") + public LinkConsentResponse consentLink(HttpServletRequest request) { + + return new LinkConsentResponse(authService.consentLink( + cookieManager.readLinkTicket(request).orElse(null))); + } + + @GetMapping("/link/reauth/{provider}") + public void linkReauth( + @PathVariable String provider, + HttpServletResponse response + ) throws IOException { + response.sendRedirect(authService.oauthAuthorizationUrl(provider)); + } + + @PostMapping("/link/cancel") + public void cancelLink( + HttpServletRequest request, + HttpServletResponse response + ) { + authService.cancelLink(cookieManager.readLinkTicket(request)); + cookieManager.clearLinkTicketCookie(response); + } +} diff --git a/auth/src/main/java/com/nalssilog/auth/config/AuthCookieManager.java b/auth/src/main/java/com/nalssilog/auth/web/AuthCookieManager.java similarity index 98% rename from auth/src/main/java/com/nalssilog/auth/config/AuthCookieManager.java rename to auth/src/main/java/com/nalssilog/auth/web/AuthCookieManager.java index 53c66ae..9b185b3 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/AuthCookieManager.java +++ b/auth/src/main/java/com/nalssilog/auth/web/AuthCookieManager.java @@ -1,5 +1,6 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.web; +import com.nalssilog.auth.config.AuthProperties; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -34,9 +35,11 @@ public void addAuthCookies(HttpServletResponse response, String accessToken, Str public void addAuthCookies(HttpServletResponse response, String accessToken, String refreshToken, Duration refreshTokenMaxAge) { addAccessTokenCookie(response, accessToken); + Duration maxAge = refreshTokenMaxAge == null ? properties.jwt().refreshTokenTtl() : refreshTokenMaxAge; + addCookie(response, REFRESH_TOKEN_COOKIE, refreshToken, maxAge); } @@ -46,10 +49,12 @@ public void clearAuthCookies(HttpServletResponse response) { } public Optional readAccessToken(HttpServletRequest request) { + return readCookie(request, ACCESS_TOKEN_COOKIE); } public Optional readRefreshToken(HttpServletRequest request) { + return readCookie(request, REFRESH_TOKEN_COOKIE); } @@ -62,6 +67,7 @@ public void clearSignupTicketCookie(HttpServletResponse response) { } public Optional readSignupTicket(HttpServletRequest request) { + return readCookie(request, SIGNUP_TICKET_COOKIE); } @@ -74,6 +80,7 @@ public void clearLinkTicketCookie(HttpServletResponse response) { } public Optional readLinkTicket(HttpServletRequest request) { + return readCookie(request, LINK_TICKET_COOKIE); } @@ -86,6 +93,7 @@ public void clearLinkIntentCookie(HttpServletResponse response) { } public Optional readLinkIntent(HttpServletRequest request) { + return readCookie(request, LINK_INTENT_COOKIE); } @@ -104,6 +112,7 @@ private void addCookie(HttpServletResponse response, String name, String value, private Optional readCookie(HttpServletRequest request, String name) { if (request.getCookies() == null) { + return Optional.empty(); } diff --git a/auth/src/main/java/com/nalssilog/auth/api/dto/AuthResult.java b/auth/src/main/java/com/nalssilog/auth/web/AuthResult.java similarity index 86% rename from auth/src/main/java/com/nalssilog/auth/api/dto/AuthResult.java rename to auth/src/main/java/com/nalssilog/auth/web/AuthResult.java index 0500810..19c5137 100644 --- a/auth/src/main/java/com/nalssilog/auth/api/dto/AuthResult.java +++ b/auth/src/main/java/com/nalssilog/auth/web/AuthResult.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.api.dto; +package com.nalssilog.auth.web; /** * 세션 조회로 표현되는 안정 상태. (콜백 전용 전이 상태 LINK_SUCCESS/LINK_FAILED/FAILED 는 여기 없음) diff --git a/auth/src/main/java/com/nalssilog/auth/api/dto/LinkConsentResponse.java b/auth/src/main/java/com/nalssilog/auth/web/LinkConsentResponse.java similarity index 84% rename from auth/src/main/java/com/nalssilog/auth/api/dto/LinkConsentResponse.java rename to auth/src/main/java/com/nalssilog/auth/web/LinkConsentResponse.java index 9dc9c09..db5eb3f 100644 --- a/auth/src/main/java/com/nalssilog/auth/api/dto/LinkConsentResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/web/LinkConsentResponse.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.api.dto; +package com.nalssilog.auth.web; /** * 연동 동의 응답. 프론트는 이 URL 로 브라우저를 이동시키기만 한다(기존 provider 재인증 시작). diff --git a/auth/src/main/java/com/nalssilog/auth/api/dto/MeResponse.java b/auth/src/main/java/com/nalssilog/auth/web/MeResponse.java similarity index 96% rename from auth/src/main/java/com/nalssilog/auth/api/dto/MeResponse.java rename to auth/src/main/java/com/nalssilog/auth/web/MeResponse.java index a632b52..f54534c 100644 --- a/auth/src/main/java/com/nalssilog/auth/api/dto/MeResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/web/MeResponse.java @@ -1,6 +1,6 @@ -package com.nalssilog.auth.api.dto; +package com.nalssilog.auth.web; -import com.nalssilog.auth.application.AuthService.MeState; +import com.nalssilog.auth.core.AuthService.MeState; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.domain.AvatarType; import com.nalssilog.member.domain.Provider; @@ -38,19 +38,23 @@ public static MeResponse authenticated(MemberInfo member) { } public static MeResponse signupRequired(Provider provider, String email) { + return new MeResponse(false, AuthResult.SIGNUP_REQUIRED, null, new PendingAuth(provider, email, List.of())); } public static MeResponse linkRequired(Provider provider, String email, List existingProviders) { + return new MeResponse(false, AuthResult.LINK_REQUIRED, null, new PendingAuth(provider, email, existingProviders)); } public static MeResponse none() { + return new MeResponse(false, AuthResult.NONE, null, null); } public static MeResponse from(MeState state) { + return switch (state.status()) { case AUTHENTICATED -> authenticated(state.member()); case SIGNUP_REQUIRED -> signupRequired(state.provider(), state.email()); diff --git a/auth/src/main/java/com/nalssilog/auth/api/dto/SessionResponse.java b/auth/src/main/java/com/nalssilog/auth/web/SessionResponse.java similarity index 88% rename from auth/src/main/java/com/nalssilog/auth/api/dto/SessionResponse.java rename to auth/src/main/java/com/nalssilog/auth/web/SessionResponse.java index 4bec61d..7de699a 100644 --- a/auth/src/main/java/com/nalssilog/auth/api/dto/SessionResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/web/SessionResponse.java @@ -1,6 +1,6 @@ -package com.nalssilog.auth.api.dto; +package com.nalssilog.auth.web; -import com.nalssilog.auth.application.dto.SessionView; +import com.nalssilog.auth.token.SessionView; import java.time.Instant; /** @@ -17,6 +17,7 @@ public record SessionResponse( ) { public static SessionResponse from(SessionView view) { + return new SessionResponse( view.sessionId(), view.deviceName(), diff --git a/auth/src/main/java/com/nalssilog/auth/api/dto/SignupRequest.java b/auth/src/main/java/com/nalssilog/auth/web/SignupRequest.java similarity index 94% rename from auth/src/main/java/com/nalssilog/auth/api/dto/SignupRequest.java rename to auth/src/main/java/com/nalssilog/auth/web/SignupRequest.java index 0d7a0dd..d96a447 100644 --- a/auth/src/main/java/com/nalssilog/auth/api/dto/SignupRequest.java +++ b/auth/src/main/java/com/nalssilog/auth/web/SignupRequest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.api.dto; +package com.nalssilog.auth.web; import com.nalssilog.member.application.dto.TermsAgreement; import jakarta.validation.Valid; diff --git a/auth/src/test/java/com/nalssilog/auth/core/AuthServiceSignupTest.java b/auth/src/test/java/com/nalssilog/auth/core/AuthServiceSignupTest.java new file mode 100644 index 0000000..51f945a --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/core/AuthServiceSignupTest.java @@ -0,0 +1,129 @@ +package com.nalssilog.auth.core; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.ticket.AuthTicketStore.SignupClaim; +import com.nalssilog.auth.ticket.AuthTicketStore.SignupClaimStatus; +import com.nalssilog.auth.ticket.AuthTicketStore.SignupCompletion; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.token.AuthSessionService; +import com.nalssilog.auth.token.AuthTokenService; +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.time.Duration; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class AuthServiceSignupTest { + + 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 AuthService service = new AuthService( + memberClient, + tokenService, + sessionService, + ticketStore, + properties()); + + @Test + void completedSignupRetryReturnsTheSameTokenPair() { + SignupCompletion completion = new SignupCompletion( + 7L, + Provider.KAKAO, + "same-access-token", + "same-refresh-token", + Duration.ofDays(14).toMillis(), + Instant.now().toEpochMilli()); + + when(ticketStore.claimSignup( + org.mockito.ArgumentMatchers.eq("ticket"), + anyString(), + org.mockito.ArgumentMatchers.eq(Duration.ofMinutes(10)))) + .thenReturn(new SignupClaim( + SignupClaimStatus.COMPLETED, + null, + completion)); + when(memberClient.getMemberInfo(7L)).thenReturn(member()); + + AuthService.SignupResult result = service.signupMobile( + "ticket", + List.of(), + new DeviceInfo( + "ANDROID · Galaxy · 0.1.0", + "203.0.113.10")); + + assertThat(result.tokens().accessToken()) + .isEqualTo("same-access-token"); + assertThat(result.tokens().refreshToken()) + .isEqualTo("same-refresh-token"); + assertThat(result.tokens().refreshTokenMaxAge()) + .isLessThanOrEqualTo(Duration.ofDays(14)) + .isGreaterThan(Duration.ofDays(13)); + verifyNoInteractions(tokenService); + } + + @Test + void mobileRefreshFailureDoesNotRequestWebCookieDeletion() { + DeviceInfo device = new DeviceInfo( + "ANDROID · Galaxy · 0.1.0", + "203.0.113.10"); + + when(tokenService.refresh("refresh-token", device)) + .thenThrow(new NalssiLogException( + AuthErrorCode.AUTH_SESSION_EXPIRED)); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.refreshMobile("refresh-token", device)); + + assertThat(exception.getErrorCode()) + .isEqualTo(AuthErrorCode.AUTH_SESSION_EXPIRED); + assertThat(exception) + .isNotInstanceOf(RefreshRejectedException.class); + } + + private MemberInfo member() { + + return new MemberInfo( + 7L, + "사용자", + "구름산책", + "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(false), + 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/mobile/guest/MobileGuestCredentialCleanupServiceTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupServiceTest.java new file mode 100644 index 0000000..8bef85d --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupServiceTest.java @@ -0,0 +1,55 @@ +package com.nalssilog.auth.mobile.guest; + +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.auth.config.AuthProperties; +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +class MobileGuestCredentialCleanupServiceTest { + + private final MobileGuestCredentialRepository repository = + mock(MobileGuestCredentialRepository.class); + private final MobileGuestCredentialCleanupService service = + new MobileGuestCredentialCleanupService(repository, properties()); + + @Test + void deletesOnlyCredentialsPastTheConfiguredRetention() { + Instant now = Instant.parse("2026-07-30T00:00:00Z"); + Instant cutoff = now.minus(Duration.ofDays(7)); + + when(repository.deleteByExpiresAtLessThanEqual(cutoff)).thenReturn(12); + + int deleted = service.cleanupExpiredCredentialsAt(now); + + assertThat(deleted).isEqualTo(12); + verify(repository).deleteByExpiresAtLessThanEqual(cutoff); + } + + 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(false), + new AuthProperties.Ticket(Duration.ofMinutes(10)), + new AuthProperties.Csrf("XSRF-TOKEN", null), + new AuthProperties.Refresh(Duration.ofSeconds(5)), + null, + new AuthProperties.Guest( + Duration.ofDays(365), + 300, + Duration.ofMinutes(10), + 3_000, + Duration.ofMinutes(1), + Duration.ofDays(7), + Duration.ofHours(6), + Duration.ofMinutes(1))); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialServiceTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialServiceTest.java new file mode 100644 index 0000000..e3aa68a --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialServiceTest.java @@ -0,0 +1,78 @@ +package com.nalssilog.auth.mobile.guest; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.common.security.SecretFingerprint; +import java.time.Duration; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +@SuppressWarnings("java:S5960") +class MobileGuestCredentialServiceTest { + + private final MobileGuestCredentialRepository repository = + mock(MobileGuestCredentialRepository.class); + private final MobileGuestIssuanceRateLimiter rateLimiter = + mock(MobileGuestIssuanceRateLimiter.class); + private final MobileGuestCredentialService service = + new MobileGuestCredentialService( + repository, + rateLimiter, + properties()); + + @Test + void issueReturnsRawSecretOnlyOnceAndPersistsItsHash() { + var issued = service.issue("203.0.113.10"); + ArgumentCaptor credentialCaptor = + ArgumentCaptor.forClass(MobileGuestCredential.class); + + verify(rateLimiter).check("203.0.113.10"); + verify(repository).save(credentialCaptor.capture()); + + MobileGuestCredential persisted = credentialCaptor.getValue(); + + assertThat(issued.token()).hasSize(43); + assertThat(issued.expiresIn()).isEqualTo(Duration.ofDays(365)); + assertThat(persisted.getTokenHash()) + .isEqualTo(SecretFingerprint.sha256(issued.token())) + .doesNotContain(issued.token()); + assertThat(persisted.getAnonymousKey()).isNotBlank(); + } + + @Test + void authenticateResolvesTheInternalAnonymousKey() { + String token = "guest-token"; + MobileGuestCredential credential = + MobileGuestCredential.issue( + SecretFingerprint.sha256(token), + "anonymous-key", + java.time.Instant.now().plus(Duration.ofHours(1))); + + when(repository.findByTokenHash(any())) + .thenReturn(Optional.of(credential)); + + assertThat(service.authenticate(token)) + .isEqualTo("anonymous-key"); + verify(repository).findByTokenHash( + SecretFingerprint.sha256(token)); + } + + 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(false), + 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/mobile/guest/MobileGuestIssuanceRateLimiterTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiterTest.java new file mode 100644 index 0000000..4968086 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiterTest.java @@ -0,0 +1,119 @@ +package com.nalssilog.auth.mobile.guest; + +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.anyList; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.common.exception.NalssiLogException; +import java.time.Duration; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.script.RedisScript; + +@SuppressWarnings({"unchecked", "java:S5960"}) +class MobileGuestIssuanceRateLimiterTest { + + private final StringRedisTemplate redisTemplate = + mock(StringRedisTemplate.class); + private final MobileGuestIssuanceRateLimiter limiter = + new MobileGuestIssuanceRateLimiter(redisTemplate, properties()); + + @Test + void sharedIpUsesHmacFingerprintAndGenerousHardLimit() { + when(redisTemplate.execute( + any(RedisScript.class), + anyList(), + any(), + any(), + any(), + any())).thenReturn(0L); + + limiter.check("203.0.113.10"); + + ArgumentCaptor> keys = ArgumentCaptor.forClass(List.class); + + org.mockito.Mockito.verify(redisTemplate).execute( + any(RedisScript.class), + keys.capture(), + org.mockito.ArgumentMatchers.eq("600000"), + org.mockito.ArgumentMatchers.eq("60000"), + org.mockito.ArgumentMatchers.eq("300"), + org.mockito.ArgumentMatchers.eq("3000")); + assertThat(keys.getValue()) + .hasSize(2) + .allMatch(key -> !key.contains("203.0.113.10")); + assertThat(keys.getValue().getFirst()).startsWith("auth:guest:issue:ip:"); + assertThat(keys.getValue().getLast()).isEqualTo("auth:guest:issue:global"); + } + + @Test + void positiveScriptResultMeansIpHardLimitWasExceeded() { + when(redisTemplate.execute( + any(RedisScript.class), + anyList(), + any(), + any(), + any(), + any())).thenReturn(301L); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> limiter.check("203.0.113.10")); + + assertThat(exception.getErrorCode()) + .isEqualTo(AuthErrorCode.GUEST_ISSUANCE_RATE_LIMITED); + } + + @Test + void negativeScriptResultMeansGlobalSafetyLimitWasExceeded() { + when(redisTemplate.execute( + any(RedisScript.class), + anyList(), + any(), + any(), + any(), + any())).thenReturn(-3_001L); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> limiter.check("198.51.100.8")); + + assertThat(exception.getErrorCode()) + .isEqualTo(AuthErrorCode.GUEST_ISSUANCE_RATE_LIMITED); + } + + 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(false), + new AuthProperties.Ticket(Duration.ofMinutes(10)), + new AuthProperties.Csrf("XSRF-TOKEN", null), + new AuthProperties.Refresh(Duration.ofSeconds(5)), + new AuthProperties.Mobile( + List.of("nalssilog-dev://auth/callback"), + Duration.ofMinutes(10), + Duration.ofSeconds(90), + "test-hmac-secret", + List.of("127.0.0.0/8")), + new AuthProperties.Guest( + Duration.ofDays(365), + 300, + Duration.ofMinutes(10), + 3_000, + Duration.ofMinutes(1), + Duration.ofDays(7), + Duration.ofHours(6), + Duration.ofMinutes(1))); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolverTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolverTest.java new file mode 100644 index 0000000..89e2f57 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolverTest.java @@ -0,0 +1,55 @@ +package com.nalssilog.auth.mobile.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; + +class MobileOAuthAuthorizationRequestResolverTest { + + @Test + void appleAuthorizationUsesFormPostResponseMode() { + ClientRegistration apple = ClientRegistration + .withRegistrationId("apple") + .clientId("com.nalssilog.login") + .clientSecret("generated-per-request") + .clientAuthenticationMethod( + ClientAuthenticationMethod.CLIENT_SECRET_POST) + .authorizationGrantType( + AuthorizationGrantType.AUTHORIZATION_CODE) + .redirectUri( + "{baseUrl}/login/oauth2/code/{registrationId}") + .scope("openid", "email", "name") + .authorizationUri( + "https://appleid.apple.com/auth/authorize") + .tokenUri("https://appleid.apple.com/auth/token") + .jwkSetUri("https://appleid.apple.com/auth/keys") + .userNameAttributeName("sub") + .clientName("Apple") + .build(); + MobileOAuthAuthorizationRequestResolver resolver = + new MobileOAuthAuthorizationRequestResolver( + new InMemoryClientRegistrationRepository(apple), + mock(MobileOAuthTransactionStore.class)); + MockHttpServletRequest request = new MockHttpServletRequest( + "GET", + "/oauth2/authorization/apple"); + + request.setServletPath("/oauth2/authorization/apple"); + + OAuth2AuthorizationRequest authorization = + resolver.resolve(request, "apple"); + + assertThat(authorization).isNotNull(); + assertThat(authorization.getAdditionalParameters()) + .containsEntry("response_mode", "form_post"); + assertThat(authorization.getAuthorizationRequestUri()) + .contains("response_mode=form_post"); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java new file mode 100644 index 0000000..c8bbb0f --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java @@ -0,0 +1,244 @@ +package com.nalssilog.auth.mobile.oauth; + +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.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import com.nalssilog.auth.config.AuthProperties; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.oauth.OAuthUserInfo; +import com.nalssilog.auth.oauth.SocialAuthPrincipal; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.token.AuthTokenService; +import com.nalssilog.auth.token.TokenPair; +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.application.dto.SocialLoginResult; +import com.nalssilog.member.domain.AvatarType; +import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.Provider; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; + +@SuppressWarnings("java:S5960") +class MobileOAuthServiceTest { + + private static final String REDIRECT_URI = + "nalssilog://auth/callback"; + private static final String CHALLENGE = "A".repeat(43); + private static final String STATE = "state-01234567890"; + + private final MobileOAuthTransactionStore transactionStore = + mock(MobileOAuthTransactionStore.class); + private final MobileOAuthCodeStore codeStore = + mock(MobileOAuthCodeStore.class); + private final AuthTicketStore ticketStore = + mock(AuthTicketStore.class); + private final MemberClient memberClient = mock(MemberClient.class); + private final AuthTokenService authTokenService = + mock(AuthTokenService.class); + private final ClientRegistrationRepository registrations = + mock(ClientRegistrationRepository.class); + private final MobileOAuthService service = new MobileOAuthService( + transactionStore, + codeStore, + ticketStore, + memberClient, + authTokenService, + properties(), + registrations); + + @Test + void loginStartStoresServerTransactionAndRedirectsThroughSpringOAuth() { + when(registrations.findByRegistrationId("kakao")) + .thenReturn(mock(ClientRegistration.class)); + + String authorizationUrl = service.startLogin( + "kakao", + REDIRECT_URI, + CHALLENGE, + "S256", + STATE); + + ArgumentCaptor idCaptor = + ArgumentCaptor.forClass(String.class); + ArgumentCaptor transactionCaptor = + ArgumentCaptor.forClass(MobileOAuthTransaction.class); + + verify(transactionStore).save( + idCaptor.capture(), + transactionCaptor.capture()); + + assertThat(idCaptor.getValue()).hasSize(43); + assertThat(transactionCaptor.getValue()) + .isEqualTo(new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN, + Provider.KAKAO, + REDIRECT_URI, + CHALLENGE, + STATE, + null, + null)); + assertThat(authorizationUrl) + .startsWith("/oauth2/authorization/kakao?mobile_transaction=") + .doesNotContain(REDIRECT_URI, CHALLENGE, STATE); + } + + @Test + void oauthCallbackOnlyIssuesOneTimeCodeAndDoesNotCreateSession() { + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN, + Provider.KAKAO, + REDIRECT_URI, + CHALLENGE, + STATE, + null, + null); + SocialAuthPrincipal principal = new SocialAuthPrincipal( + SocialLoginResult.existing(7L, MemberStatus.ACTIVE), + new OAuthUserInfo( + Provider.KAKAO, + "provider-user", + "user@example.com", + "사용자"), + Map.of()); + + when(transactionStore.take("transaction")) + .thenReturn(Optional.of(transaction)); + + String callback = service.complete("transaction", principal); + + ArgumentCaptor codeCaptor = + ArgumentCaptor.forClass(String.class); + ArgumentCaptor grantCaptor = + ArgumentCaptor.forClass(MobileOAuthGrant.class); + + verify(codeStore).save( + codeCaptor.capture(), + grantCaptor.capture(), + org.mockito.ArgumentMatchers.eq(REDIRECT_URI), + org.mockito.ArgumentMatchers.eq(CHALLENGE)); + + assertThat(codeCaptor.getValue()).hasSize(43); + assertThat(grantCaptor.getValue()) + .isEqualTo(MobileOAuthGrant.success( + 7L, + Provider.KAKAO)); + assertThat(callback) + .contains("code=" + codeCaptor.getValue(), "state=" + STATE) + .doesNotContain( + "accessToken", + "refreshToken", + "user@example.com", + "provider-user"); + verifyNoInteractions(authTokenService); + } + + @Test + void tokensAreCreatedOnlyAfterSuccessfulCodeExchange() { + String verifier = "v".repeat(43); + DeviceInfo device = new DeviceInfo( + "ANDROID · Galaxy · 0.1.0", + "203.0.113.10"); + MemberInfo member = member(); + TokenPair tokens = new TokenPair( + "access-token", + "refresh-token", + Duration.ofDays(14)); + + when(codeStore.consume( + org.mockito.ArgumentMatchers.eq("one-time-code"), + org.mockito.ArgumentMatchers.eq(REDIRECT_URI), + any())) + .thenReturn(MobileOAuthGrant.success( + 7L, + Provider.KAKAO)); + when(memberClient.getMemberInfo(7L)).thenReturn(member); + when(authTokenService.issue( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + device)).thenReturn(tokens); + + MobileOAuthService.ExchangeResult result = service.exchange( + "one-time-code", + verifier, + REDIRECT_URI, + device); + + assertThat(result.result()).isEqualTo(MobileAuthResult.SUCCESS); + assertThat(result.tokens()).isEqualTo(tokens); + assertThat(result.member()).isEqualTo(member); + verify(authTokenService).issue( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + device); + } + + @Test + void unregisteredRedirectIsRejectedBeforeOAuthStateIsCreated() { + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.startLogin( + "kakao", + "nalssilog://attacker/callback", + CHALLENGE, + "S256", + STATE)); + + assertThat(exception.getErrorCode()) + .isEqualTo(AuthErrorCode.AUTH_REDIRECT_URI_INVALID); + verify(transactionStore, never()).save(any(), any()); + } + + private MemberInfo member() { + + return new MemberInfo( + 7L, + "사용자", + "구름산책", + "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(false), + new AuthProperties.Ticket(Duration.ofMinutes(10)), + new AuthProperties.Csrf("XSRF-TOKEN", null), + new AuthProperties.Refresh(Duration.ofSeconds(5)), + new AuthProperties.Mobile( + List.of(REDIRECT_URI), + Duration.ofMinutes(10), + Duration.ofSeconds(90), + "test-hmac-secret", + List.of("127.0.0.0/8")), + new AuthProperties.Guest( + Duration.ofDays(365), + 5, + Duration.ofMinutes(10))); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/application/CustomOAuth2UserServiceTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/CustomOAuth2UserServiceTest.java similarity index 97% rename from auth/src/test/java/com/nalssilog/auth/application/CustomOAuth2UserServiceTest.java rename to auth/src/test/java/com/nalssilog/auth/oauth/CustomOAuth2UserServiceTest.java index 1448ff8..6ea6b9d 100644 --- a/auth/src/test/java/com/nalssilog/auth/application/CustomOAuth2UserServiceTest.java +++ b/auth/src/test/java/com/nalssilog/auth/oauth/CustomOAuth2UserServiceTest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.oauth; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatCode; diff --git a/auth/src/test/java/com/nalssilog/auth/config/OAuth2LoginFailureHandlerTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandlerTest.java similarity index 87% rename from auth/src/test/java/com/nalssilog/auth/config/OAuth2LoginFailureHandlerTest.java rename to auth/src/test/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandlerTest.java index 4dad2b5..d85d8a1 100644 --- a/auth/src/test/java/com/nalssilog/auth/config/OAuth2LoginFailureHandlerTest.java +++ b/auth/src/test/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandlerTest.java @@ -1,8 +1,9 @@ -package com.nalssilog.auth.config; +package com.nalssilog.auth.oauth; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; -import com.nalssilog.auth.application.CustomOAuth2UserService; +import com.nalssilog.auth.mobile.oauth.MobileOAuthService; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; @@ -15,8 +16,11 @@ class OAuth2LoginFailureHandlerTest { @Test void missingEmailRedirectsWithDedicatedFailureCode() throws Exception { - OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler(); + OAuth2LoginFailureHandler handler = new OAuth2LoginFailureHandler( + mock(MobileOAuthService.class)); + ReflectionTestUtils.setField(handler, "frontendBaseUrl", "https://dev.nalssilog.com"); + MockHttpServletResponse response = new MockHttpServletResponse(); handler.onAuthenticationFailure( diff --git a/auth/src/test/java/com/nalssilog/auth/client/OAuthUserInfoTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/OAuthUserInfoTest.java similarity index 72% rename from auth/src/test/java/com/nalssilog/auth/client/OAuthUserInfoTest.java rename to auth/src/test/java/com/nalssilog/auth/oauth/OAuthUserInfoTest.java index 5c4ceb1..cc46330 100644 --- a/auth/src/test/java/com/nalssilog/auth/client/OAuthUserInfoTest.java +++ b/auth/src/test/java/com/nalssilog/auth/oauth/OAuthUserInfoTest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.client; +package com.nalssilog.auth.oauth; import static org.assertj.core.api.Assertions.assertThat; @@ -66,4 +66,31 @@ void naverFallsBackToNicknameWhenNameIsBlank() { assertThat(userInfo.socialName()).isEqualTo(NAVER_NICKNAME); } + + @Test + void appleOidcClaimsBecomeSocialIdentity() { + OAuthUserInfo userInfo = OAuthUserInfo.of("apple", Map.of( + "sub", "apple-subject", + EMAIL_KEY, USER_EMAIL + )); + + assertThat(userInfo).isEqualTo(new OAuthUserInfo( + Provider.APPLE, + "apple-subject", + USER_EMAIL, + null)); + } + + @Test + void appleUsesTheNameFromTheFirstFormPostAuthorization() { + OAuthUserInfo userInfo = OAuthUserInfo.of( + "apple", + Map.of( + "sub", "apple-subject", + EMAIL_KEY, USER_EMAIL), + "Gil-dong Hong"); + + assertThat(userInfo.socialName()).isEqualTo("Gil-dong Hong"); + assertThat(userInfo.email()).isEqualTo(USER_EMAIL); + } } diff --git a/auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilterTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilterTest.java new file mode 100644 index 0000000..3691bf5 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilterTest.java @@ -0,0 +1,48 @@ +package com.nalssilog.auth.oauth.apple; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.concurrent.atomic.AtomicReference; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import tools.jackson.databind.ObjectMapper; + +class AppleAuthorizationResponseFilterTest { + + @Test + void exposesFirstAuthorizationNameOnlyWhileCallbackIsProcessed() throws Exception { + AppleAuthorizationUserContext context = + new AppleAuthorizationUserContext(); + AppleAuthorizationResponseFilter filter = + new AppleAuthorizationResponseFilter(new ObjectMapper(), context); + MockHttpServletRequest request = new MockHttpServletRequest( + "POST", + AppleAuthorizationResponseFilter.CALLBACK_PATH); + + request.setParameter( + "user", + """ + { + "name": { + "firstName": "Gil-dong", + "lastName": "Hong" + }, + "email": "must-not-be-trusted@example.com" + } + """); + + AtomicReference nameSeenDuringCallback = + new AtomicReference<>(); + + filter.doFilter( + request, + new MockHttpServletResponse(), + (nestedRequest, nestedResponse) -> + nameSeenDuringCallback.set( + context.currentSocialName().orElse(null))); + + assertThat(nameSeenDuringCallback.get()).isEqualTo("Gil-dong Hong"); + assertThat(context.currentSocialName()).isEmpty(); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGeneratorTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGeneratorTest.java new file mode 100644 index 0000000..b9dfc76 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGeneratorTest.java @@ -0,0 +1,58 @@ +package com.nalssilog.auth.oauth.apple; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.spec.ECGenParameterSpec; +import java.time.Duration; +import java.time.Instant; +import java.util.Base64; +import org.junit.jupiter.api.Test; + +class AppleClientSecretGeneratorTest { + + @Test + void generatesAndCachesAppleEs256ClientSecret() throws Exception { + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("EC"); + + keyPairGenerator.initialize(new ECGenParameterSpec("secp256r1")); + + KeyPair keyPair = keyPairGenerator.generateKeyPair(); + String privateKey = """ + -----BEGIN PRIVATE KEY----- + %s + -----END PRIVATE KEY----- + """.formatted(Base64.getMimeEncoder(64, new byte[]{'\n'}) + .encodeToString(keyPair.getPrivate().getEncoded())); + AppleOAuthProperties properties = new AppleOAuthProperties( + "TEAM123456", + "KEY1234567", + null, + Base64.getEncoder().encodeToString( + privateKey.getBytes(java.nio.charset.StandardCharsets.UTF_8)), + Duration.ofDays(30), + Duration.ofDays(1)); + AppleClientSecretGenerator generator = + new AppleClientSecretGenerator(properties); + + String first = generator.generate("com.nalssilog.login"); + String second = generator.generate("com.nalssilog.login"); + Jws parsed = Jwts.parser() + .verifyWith(keyPair.getPublic()) + .build() + .parseSignedClaims(first); + + assertThat(second).isEqualTo(first); + assertThat(parsed.getHeader().getKeyId()).isEqualTo("KEY1234567"); + assertThat(parsed.getPayload().getIssuer()).isEqualTo("TEAM123456"); + assertThat(parsed.getPayload().getSubject()).isEqualTo("com.nalssilog.login"); + assertThat(parsed.getPayload().getAudience()) + .containsExactly("https://appleid.apple.com"); + assertThat(parsed.getPayload().getExpiration().toInstant()) + .isAfter(Instant.now().plus(Duration.ofDays(29))); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/application/AuthTokenServiceTest.java b/auth/src/test/java/com/nalssilog/auth/token/AuthTokenServiceTest.java similarity index 93% rename from auth/src/test/java/com/nalssilog/auth/application/AuthTokenServiceTest.java rename to auth/src/test/java/com/nalssilog/auth/token/AuthTokenServiceTest.java index 29bce94..cfa9dcd 100644 --- a/auth/src/test/java/com/nalssilog/auth/application/AuthTokenServiceTest.java +++ b/auth/src/test/java/com/nalssilog/auth/token/AuthTokenServiceTest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.token; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowableOfType; @@ -10,15 +10,13 @@ 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.auth.core.AuthErrorCode; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.token.RefreshTokenStore.RotationResult; +import com.nalssilog.auth.token.RefreshTokenStore.RotationStatus; +import com.nalssilog.auth.token.RefreshTokenStore.UsedToken; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.domain.AvatarType; @@ -73,7 +71,8 @@ void setUp() { MemberStatus.ACTIVE, Provider.KAKAO, List.of(Provider.KAKAO)); - when(jwtTokenProvider.createAccessToken(1L, MemberStatus.ACTIVE, Provider.KAKAO)) + when(jwtTokenProvider.createAccessToken( + 1L, MemberStatus.ACTIVE, Provider.KAKAO, SESSION_ID)) .thenReturn("access-token"); } @@ -90,6 +89,7 @@ void rotatesCurrentRefreshTokenThroughAtomicStoreOperation() { .thenAnswer(invocation -> { String token = invocation.getArgument(1); SessionData replacement = invocation.getArgument(2); + return new RotationResult( RotationStatus.ROTATED, token, @@ -125,6 +125,7 @@ void duplicateWithinGraceReplaysTheFirstReplacementToken() { "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()))); @@ -164,6 +165,7 @@ void reuseOutsideGraceRevokesTheWholeDeviceSession() { "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()))); @@ -205,6 +207,7 @@ void unknownRefreshTokenIsRejectedWithoutRevokingUnrelatedSessions() { } private static AuthProperties properties() { + return new AuthProperties( new AuthProperties.Jwt( "test-secret-must-be-at-least-thirty-two-bytes", @@ -219,6 +222,7 @@ private static AuthProperties properties() { 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/token/JwtAuthenticationFilterTest.java b/auth/src/test/java/com/nalssilog/auth/token/JwtAuthenticationFilterTest.java new file mode 100644 index 0000000..2f74f2d --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/token/JwtAuthenticationFilterTest.java @@ -0,0 +1,143 @@ +package com.nalssilog.auth.token; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.nalssilog.auth.security.ApiAuthenticationEntryPoint; +import com.nalssilog.auth.web.AuthCookieManager; +import com.nalssilog.common.security.VerifiedRequestCredentials; +import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.Provider; +import java.time.Duration; +import java.util.concurrent.atomic.AtomicBoolean; +import org.junit.jupiter.api.AfterEach; +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; +import org.springframework.security.core.context.SecurityContextHolder; +import tools.jackson.databind.ObjectMapper; + +@SuppressWarnings("java:S5960") +class JwtAuthenticationFilterTest { + + private final AuthCookieManager cookieManager = + mock(AuthCookieManager.class); + private final RefreshTokenStore refreshTokenStore = + mock(RefreshTokenStore.class); + private final JwtTokenProvider tokenProvider = + new JwtTokenProvider(TestAuthProperties.create()); + private final JwtAuthenticationFilter filter = + new JwtAuthenticationFilter( + cookieManager, + tokenProvider, + refreshTokenStore, + new ApiAuthenticationEntryPoint(new ObjectMapper())); + + @BeforeEach + @AfterEach + void clearSecurityContext() { + SecurityContextHolder.clearContext(); + } + + @Test + void validBearerIsAuthenticatedWithoutConsultingCookies() throws Exception { + String accessToken = tokenProvider.createAccessToken( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + "session-7"); + MockHttpServletRequest request = + new MockHttpServletRequest("GET", "/api/members/me"); + + request.addHeader( + HttpHeaders.AUTHORIZATION, + "Bearer " + accessToken); + + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.doFilter(request, response, (servletRequest, servletResponse) -> { + invoked.set(true); + + var authentication = + SecurityContextHolder.getContext().getAuthentication(); + + assertThat(authentication.getPrincipal()).isEqualTo(7L); + assertThat(authentication.getDetails()) + .isEqualTo(new AuthRequestDetails( + Provider.KAKAO, + "session-7", + CredentialTransport.BEARER)); + }); + + assertThat(invoked).isTrue(); + assertThat(response.getStatus()).isEqualTo(200); + assertThat(VerifiedRequestCredentials.hasBearer(request)).isTrue(); + verifyNoInteractions(cookieManager); + } + + @Test + void malformedAuthorizationNeverFallsBackToCookieAuthentication() + throws Exception { + MockHttpServletRequest request = + new MockHttpServletRequest("GET", "/api/members/me"); + + request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer"); + + MockHttpServletResponse response = new MockHttpServletResponse(); + AtomicBoolean invoked = new AtomicBoolean(); + + filter.doFilter( + request, + response, + (servletRequest, servletResponse) -> invoked.set(true)); + + assertThat(invoked).isFalse(); + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()) + .contains("\"code\":\"AUTH_ACCESS_TOKEN_INVALID\""); + assertThat(SecurityContextHolder.getContext().getAuthentication()) + .isNull(); + verifyNoInteractions(cookieManager); + } + + @Test + void expiredBearerReturnsTheStableExpiredCode() throws Exception { + JwtTokenProvider expiredTokenProvider = + new JwtTokenProvider(TestAuthProperties.create( + Duration.ofSeconds(-1))); + JwtAuthenticationFilter expiredFilter = + new JwtAuthenticationFilter( + cookieManager, + expiredTokenProvider, + refreshTokenStore, + new ApiAuthenticationEntryPoint(new ObjectMapper())); + String expiredToken = expiredTokenProvider.createAccessToken( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + "session-7"); + MockHttpServletRequest request = + new MockHttpServletRequest("GET", "/api/members/me"); + + request.addHeader( + HttpHeaders.AUTHORIZATION, + "Bearer " + expiredToken); + + MockHttpServletResponse response = new MockHttpServletResponse(); + + expiredFilter.doFilter( + request, + response, + (servletRequest, servletResponse) -> { + throw new AssertionError("expired bearer must stop the chain"); + }); + + assertThat(response.getStatus()).isEqualTo(401); + assertThat(response.getContentAsString()) + .contains("\"code\":\"AUTH_ACCESS_TOKEN_EXPIRED\""); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/application/JwtTokenProviderTest.java b/auth/src/test/java/com/nalssilog/auth/token/JwtTokenProviderTest.java similarity index 69% rename from auth/src/test/java/com/nalssilog/auth/application/JwtTokenProviderTest.java rename to auth/src/test/java/com/nalssilog/auth/token/JwtTokenProviderTest.java index 511b539..fd015b6 100644 --- a/auth/src/test/java/com/nalssilog/auth/application/JwtTokenProviderTest.java +++ b/auth/src/test/java/com/nalssilog/auth/token/JwtTokenProviderTest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.application; +package com.nalssilog.auth.token; import static org.assertj.core.api.Assertions.assertThat; @@ -22,7 +22,26 @@ void accessTokenPreservesAuthenticatedProvider() { 1L, MemberStatus.ACTIVE, Provider.KAKAO)); } + @Test + void accessTokenCarriesTheServerIssuedSessionId() { + JwtTokenProvider provider = new JwtTokenProvider(properties()); + + String token = provider.createAccessToken( + 1L, + MemberStatus.ACTIVE, + Provider.KAKAO, + "session-1"); + + assertThat(provider.parse(token)) + .contains(new JwtTokenProvider.AccessTokenPayload( + 1L, + MemberStatus.ACTIVE, + Provider.KAKAO, + "session-1")); + } + private AuthProperties properties() { + return new AuthProperties( new AuthProperties.Jwt( "test-secret-must-be-at-least-thirty-two-bytes", diff --git a/auth/src/test/java/com/nalssilog/auth/repository/RefreshTokenStoreTest.java b/auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreTest.java similarity index 93% rename from auth/src/test/java/com/nalssilog/auth/repository/RefreshTokenStoreTest.java rename to auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreTest.java index c3aaaa8..d221d55 100644 --- a/auth/src/test/java/com/nalssilog/auth/repository/RefreshTokenStoreTest.java +++ b/auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreTest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.repository; +package com.nalssilog.auth.token; import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; @@ -6,8 +6,7 @@ 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.auth.token.RefreshTokenStore.RotationStatus; import com.nalssilog.member.domain.Provider; import java.time.Duration; import java.time.Instant; @@ -32,6 +31,7 @@ void mapsAtomicRotationScriptResultToDomainContract() { "1", "session-1", String.valueOf(Duration.ofDays(14).toMillis()))); + SessionData replacement = new SessionData( "replacement-hash", "session-1", diff --git a/auth/src/test/java/com/nalssilog/auth/token/TestAuthProperties.java b/auth/src/test/java/com/nalssilog/auth/token/TestAuthProperties.java new file mode 100644 index 0000000..3fa946b --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/token/TestAuthProperties.java @@ -0,0 +1,28 @@ +package com.nalssilog.auth.token; + +import com.nalssilog.auth.config.AuthProperties; +import java.time.Duration; + +final class TestAuthProperties { + + private TestAuthProperties() { + } + + static AuthProperties create() { + + return create(Duration.ofMinutes(5)); + } + + static AuthProperties create(Duration accessTokenTtl) { + + return new AuthProperties( + new AuthProperties.Jwt( + "test-secret-must-be-at-least-thirty-two-bytes", + accessTokenTtl, + 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))); + } +} diff --git a/auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java b/auth/src/test/java/com/nalssilog/auth/web/AuthControllerTest.java similarity index 91% rename from auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java rename to auth/src/test/java/com/nalssilog/auth/web/AuthControllerTest.java index 58f3416..518312c 100644 --- a/auth/src/test/java/com/nalssilog/auth/api/AuthControllerTest.java +++ b/auth/src/test/java/com/nalssilog/auth/web/AuthControllerTest.java @@ -1,4 +1,4 @@ -package com.nalssilog.auth.api; +package com.nalssilog.auth.web; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.catchThrowableOfType; @@ -6,19 +6,16 @@ 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.AuthService; -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.domain.RefreshRejectedException; -import com.nalssilog.auth.repository.AuthTicketStore; +import com.nalssilog.auth.core.AuthErrorCode; +import com.nalssilog.auth.core.AuthService; +import com.nalssilog.auth.core.RefreshRejectedException; +import com.nalssilog.auth.device.DeviceInfoResolver; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.token.AuthSessionService; +import com.nalssilog.auth.token.AuthTokenService; +import com.nalssilog.auth.token.TokenPair; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; import com.nalssilog.member.domain.AvatarType; @@ -54,6 +51,7 @@ void setUp() { sessionService, ticketStore, properties); + controller = new AuthController( authService, cookieManager, @@ -71,6 +69,7 @@ void meReturnsNoneOnlyWhenNoAuthenticationCookieExists() { @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")); @@ -86,6 +85,7 @@ void meReturnsSuccessBeforeInspectingCookiesWhenAccessTokenIsValid() { @Test void meRequestsRefreshWhenAccessCookieCouldNotBeAuthenticated() { MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AuthCookieManager.ACCESS_TOKEN_COOKIE, "expired-access")); NalssiLogException exception = catchThrowableOfType( @@ -98,6 +98,7 @@ void meRequestsRefreshWhenAccessCookieCouldNotBeAuthenticated() { @Test void meRequestsRefreshWhenOnlyRefreshCookieRemains() { MockHttpServletRequest request = new MockHttpServletRequest(); + request.setCookies(new Cookie(AuthCookieManager.REFRESH_TOKEN_COOKIE, "refresh-token")); NalssiLogException exception = catchThrowableOfType( @@ -110,8 +111,11 @@ void meRequestsRefreshWhenOnlyRefreshCookieRemains() { @Test void terminalRefreshErrorIsClassifiedByAuthService() { 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)); @@ -126,8 +130,11 @@ void terminalRefreshErrorIsClassifiedByAuthService() { @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))); @@ -138,6 +145,7 @@ void refreshUsesServerRemainingTtlForHardenedHostOnlyCookie() { .filter(header -> header.startsWith("refresh_token=")) .findFirst() .orElseThrow(); + assertThat(refreshCookie) .contains("Max-Age=1123200") .contains("Path=/") @@ -149,6 +157,7 @@ void refreshUsesServerRemainingTtlForHardenedHostOnlyCookie() { } private MemberInfo member() { + return new MemberInfo( 1L, "닉네임", @@ -162,6 +171,7 @@ private MemberInfo member() { } private AuthProperties properties() { + return new AuthProperties( new AuthProperties.Jwt( "test-secret-must-be-at-least-thirty-two-bytes", diff --git a/common/build.gradle b/common/build.gradle index dd91b7a..fd405c5 100644 --- a/common/build.gradle +++ b/common/build.gradle @@ -6,4 +6,5 @@ dependencies { api "io.github.openfeign.querydsl:querydsl-jpa:${querydslVersion}" implementation 'io.hypersistence:hypersistence-tsid:2.1.4' + implementation 'org.springframework.security:spring-security-web' } diff --git a/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java b/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java index dcfd21c..9f4745d 100644 --- a/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java +++ b/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java @@ -1,7 +1,17 @@ package com.nalssilog.common.filter; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; - +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.Locale; +import java.util.Set; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; @@ -10,12 +20,6 @@ import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; - /** * 요청마다 액세스 로그 한 줄(method·path·status·소요시간·actor). {@link TraceIdFilter} 다음 순서라 traceId 가 이미 MDC 에 있다. * prod JSON 용으로 필드를 MDC 에 잠시 싣는다. 작성자: 인증 필터가 심은 request attribute({@link #ACTOR_MEMBER_ID}), 없으면 쿠키. @@ -24,77 +28,127 @@ @Order(Ordered.HIGHEST_PRECEDENCE + 1) public class RequestLoggingFilter extends OncePerRequestFilter { - /** 인증 필터가 인증된 회원의 memberId 를 실어두는 request attribute 키. */ - public static final String ACTOR_MEMBER_ID = "actorMemberId"; - - /** report 모듈의 익명 식별 쿠키명과의 계약(모듈 역의존 회피용 상수 복제). */ - private static final String ANONYMOUS_COOKIE = "anonymous_id"; - - private static final Logger log = LoggerFactory.getLogger("http.access"); - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - long startNanos = System.nanoTime(); - - try { - filterChain.doFilter(request, response); - } finally { - long durationMs = (System.nanoTime() - startNanos) / 1_000_000; - String actor = resolveActor(request); - String query = request.getQueryString(); - - MDC.put("method", request.getMethod()); - MDC.put("path", request.getRequestURI()); - MDC.put("status", String.valueOf(response.getStatus())); - MDC.put("durationMs", String.valueOf(durationMs)); - MDC.put("actor", actor); - - try { - log.info("{} {}{} -> {} ({}ms) actor={}", - request.getMethod(), - request.getRequestURI(), - query == null ? "" : "?" + query, - response.getStatus(), - durationMs, - actor); - } finally { - MDC.remove("method"); - MDC.remove("path"); - MDC.remove("status"); - MDC.remove("durationMs"); - MDC.remove("actor"); - } - } - } - - @Override - protected boolean shouldNotFilter(HttpServletRequest request) { - String uri = request.getRequestURI(); - - return uri.equals("/api/health") || uri.startsWith("/error"); - } - - private String resolveActor(HttpServletRequest request) { - Object memberId = request.getAttribute(ACTOR_MEMBER_ID); - - if (memberId != null) { - return "member:" + memberId; - } - - Cookie[] cookies = request.getCookies(); - - if (cookies != null) { - for (Cookie cookie : cookies) { - if (ANONYMOUS_COOKIE.equals(cookie.getName())) { - String value = cookie.getValue(); - - return "anon:" + (value.length() > 8 ? value.substring(0, 8) : value); - } - } - } - - return "guest"; - } + /** 인증 필터가 인증된 회원의 memberId 를 실어두는 request attribute 키. */ + public static final String ACTOR_MEMBER_ID = "actorMemberId"; + + /** report 모듈의 익명 식별 쿠키명과의 계약(모듈 역의존 회피용 상수 복제). */ + private static final String ANONYMOUS_COOKIE = "anonymous_id"; + + private static final Set SENSITIVE_QUERY_PARAMETERS = Set.of( + "code", + "state", + "code_challenge", + "codechallenge", + "code_verifier", + "codeverifier", + "mobile_transaction", + "access_token", + "refresh_token", + "refreshtoken", + "guest_token", + "guesttoken", + "signup_ticket", + "signupticket", + "link_ticket", + "linkticket" + ); + + private static final Logger log = LoggerFactory.getLogger("http.access"); + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + long startNanos = System.nanoTime(); + + try { + filterChain.doFilter(request, response); + } finally { + long durationMs = (System.nanoTime() - startNanos) / 1_000_000; + String actor = resolveActor(request); + String query = sanitizeQuery(request.getQueryString()); + + MDC.put("method", request.getMethod()); + MDC.put("path", request.getRequestURI()); + MDC.put("status", String.valueOf(response.getStatus())); + MDC.put("durationMs", String.valueOf(durationMs)); + MDC.put("actor", actor); + + try { + log.info("{} {}{} -> {} ({}ms) actor={}", + request.getMethod(), + request.getRequestURI(), + query == null ? "" : "?" + query, + response.getStatus(), + durationMs, + actor); + } finally { + MDC.remove("method"); + MDC.remove("path"); + MDC.remove("status"); + MDC.remove("durationMs"); + MDC.remove("actor"); + } + } + } + + static String sanitizeQuery(String query) { + if (query == null || query.isBlank()) { + + return null; + } + + return Arrays.stream(query.split("&", -1)) + .map(RequestLoggingFilter::sanitizeQueryPart) + .collect(Collectors.joining("&")); + } + + private static String sanitizeQueryPart(String part) { + int separator = part.indexOf('='); + String rawName = separator < 0 ? part : part.substring(0, separator); + String decodedName; + + try { + decodedName = URLDecoder.decode(rawName, StandardCharsets.UTF_8); + } catch (IllegalArgumentException _) { + decodedName = rawName; + } + + if (SENSITIVE_QUERY_PARAMETERS.contains(decodedName.toLowerCase(Locale.ROOT))) { + + return rawName + "=***"; + } + + return part; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String uri = request.getRequestURI(); + + return uri.equals("/api/health") || uri.startsWith("/error"); + } + + private String resolveActor(HttpServletRequest request) { + Object memberId = request.getAttribute(ACTOR_MEMBER_ID); + + if (memberId != null) { + + return "member:" + memberId; + } + + Cookie[] cookies = request.getCookies(); + + if (cookies != null) { + for (Cookie cookie : cookies) { + if (ANONYMOUS_COOKIE.equals(cookie.getName())) { + String value = cookie.getValue(); + + return "anon:" + (value.length() > 8 ? value.substring(0, 8) : value); + } + } + } + + return "guest"; + } } diff --git a/common/src/main/java/com/nalssilog/common/security/SecretFingerprint.java b/common/src/main/java/com/nalssilog/common/security/SecretFingerprint.java new file mode 100644 index 0000000..44c707c --- /dev/null +++ b/common/src/main/java/com/nalssilog/common/security/SecretFingerprint.java @@ -0,0 +1,41 @@ +package com.nalssilog.common.security; + +import java.nio.charset.StandardCharsets; +import java.security.InvalidKeyException; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; + +public final class SecretFingerprint { + + private static final String HMAC_ALGORITHM = "HmacSHA256"; + + private SecretFingerprint() { + } + + public 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 exception) { + throw new IllegalStateException("SHA-256 not available", exception); + } + } + + public static String hmacSha256(String secret, String value) { + try { + Mac mac = Mac.getInstance(HMAC_ALGORITHM); + + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM)); + + return HexFormat.of().formatHex(mac.doFinal(value.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new IllegalStateException("HmacSHA256 not available", exception); + } catch (InvalidKeyException exception) { + throw new IllegalStateException("HmacSHA256 key is invalid", exception); + } + } +} diff --git a/common/src/main/java/com/nalssilog/common/security/VerifiedRequestCredentials.java b/common/src/main/java/com/nalssilog/common/security/VerifiedRequestCredentials.java new file mode 100644 index 0000000..4eec01a --- /dev/null +++ b/common/src/main/java/com/nalssilog/common/security/VerifiedRequestCredentials.java @@ -0,0 +1,50 @@ +package com.nalssilog.common.security; + +import jakarta.servlet.http.HttpServletRequest; +import java.util.Optional; + +/** + * 서명·저장소 검증이 끝난 비쿠키 credential만 요청 내부에 전달하는 계약. + * 같은 이름의 외부 헤더는 읽지 않고 서버 필터만 request attribute를 설정한다. + */ +public final class VerifiedRequestCredentials { + + private static final String BEARER_ATTRIBUTE = + VerifiedRequestCredentials.class.getName() + ".bearer"; + private static final String GUEST_ATTRIBUTE = + VerifiedRequestCredentials.class.getName() + ".guestAnonymousKey"; + + private VerifiedRequestCredentials() { + } + + public static void markBearer(HttpServletRequest request) { + request.setAttribute(BEARER_ATTRIBUTE, Boolean.TRUE); + } + + public static boolean hasBearer(HttpServletRequest request) { + + return Boolean.TRUE.equals(request.getAttribute(BEARER_ATTRIBUTE)); + } + + public static void markGuest(HttpServletRequest request, String anonymousKey) { + request.setAttribute(GUEST_ATTRIBUTE, anonymousKey); + } + + public static Optional guestAnonymousKey(HttpServletRequest request) { + Object value = request.getAttribute(GUEST_ATTRIBUTE); + + return value instanceof String text && !text.isBlank() + ? Optional.of(text) + : Optional.empty(); + } + + public static boolean hasGuest(HttpServletRequest request) { + + return guestAnonymousKey(request).isPresent(); + } + + public static boolean hasNonCookieCredential(HttpServletRequest request) { + + return hasBearer(request) || hasGuest(request); + } +} diff --git a/common/src/main/java/com/nalssilog/common/web/TrustedProxyChain.java b/common/src/main/java/com/nalssilog/common/web/TrustedProxyChain.java new file mode 100644 index 0000000..aa9ce7c --- /dev/null +++ b/common/src/main/java/com/nalssilog/common/web/TrustedProxyChain.java @@ -0,0 +1,124 @@ +package com.nalssilog.common.web; + +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.util.StringUtils; + +/** + * 실제 socket peer가 명시된 trusted proxy 대역일 때만 X-Forwarded-For를 해석한다. + * 체인을 오른쪽부터 따라가 첫 untrusted hop을 실제 클라이언트 주소로 선택한다. + */ +public final class TrustedProxyChain { + + private static final int MAX_WRAPPER_DEPTH = 16; + + private final List trustedProxies; + + public TrustedProxyChain(List trustedProxies) { + List ranges = trustedProxies == null ? List.of() : trustedProxies; + + this.trustedProxies = ranges.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/common/src/test/java/com/nalssilog/common/filter/RequestLoggingFilterTest.java b/common/src/test/java/com/nalssilog/common/filter/RequestLoggingFilterTest.java new file mode 100644 index 0000000..30c22ef --- /dev/null +++ b/common/src/test/java/com/nalssilog/common/filter/RequestLoggingFilterTest.java @@ -0,0 +1,25 @@ +package com.nalssilog.common.filter; + +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class RequestLoggingFilterTest { + + @Test + void masksOAuthAndMobileCredentialQueryParameters() { + String sanitized = RequestLoggingFilter.sanitizeQuery( + "code=provider-code&state=app-state&code_challenge=challenge-value&locationId=10"); + + assertThat(sanitized) + .isEqualTo("code=***&state=***&code_challenge=***&locationId=10") + .doesNotContain("provider-code", "app-state", "challenge-value"); + } + + @Test + void recognizesEncodedSensitiveParameterName() { + assertThat(RequestLoggingFilter.sanitizeQuery("co%64e=secret")) + .isEqualTo("co%64e=***"); + } +} diff --git a/member/src/main/java/com/nalssilog/member/api/MemberController.java b/member/src/main/java/com/nalssilog/member/api/MemberController.java index 6f92430..2e77c67 100644 --- a/member/src/main/java/com/nalssilog/member/api/MemberController.java +++ b/member/src/main/java/com/nalssilog/member/api/MemberController.java @@ -38,47 +38,57 @@ public class MemberController { @GetMapping("/nickname/availability") public NicknameAvailabilityResponse checkNickname(@RequestParam @NotBlank String nickname) { + return new NicknameAvailabilityResponse(memberProfileService.isNicknameAvailable(nickname)); } @GetMapping("/me") public MemberMeResponse me( @AuthenticationPrincipal Long memberId, - @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider + @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider ) { + return MemberMeResponse.from(memberProfileService.getMe(memberId), currentProvider); } @GetMapping("/{id}") public MemberPublicProfileResponse publicProfile(@PathVariable Long id) { + return MemberPublicProfileResponse.from(memberProfileService.getPublicProfile(id)); } @PatchMapping("/me/name") public MemberMeResponse changeName( @AuthenticationPrincipal Long memberId, - @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider, + @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider, @Valid @RequestBody ChangeNameRequest request ) { + return MemberMeResponse.from(memberProfileService.changeName(memberId, request.name()), currentProvider); } @PatchMapping("/me/nickname") public MemberMeResponse changeNickname( @AuthenticationPrincipal Long memberId, - @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider, + @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider, @Valid @RequestBody ChangeNicknameRequest request ) { - return MemberMeResponse.from(memberProfileService.changeNickname(memberId, request.nickname()), currentProvider); + + return MemberMeResponse.from( + memberProfileService.changeNickname(memberId, request.nickname()), + currentProvider); } @PatchMapping("/me/avatar") public MemberMeResponse changeAvatar( @AuthenticationPrincipal Long memberId, - @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider, + @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider, @Valid @RequestBody ChangeAvatarRequest request ) { - return MemberMeResponse.from(memberProfileService.changeAvatar(memberId, request.type(), request.value()), currentProvider); + + return MemberMeResponse.from( + memberProfileService.changeAvatar(memberId, request.type(), request.value()), + currentProvider); } @PostMapping("/me/avatar/presign") @@ -86,11 +96,14 @@ public AvatarPresignResponse presignAvatar( @AuthenticationPrincipal Long memberId, @Valid @RequestBody AvatarPresignRequest request ) { - return AvatarPresignResponse.from(memberProfileService.presignAvatar(memberId, request.contentType(), request.size())); + + return AvatarPresignResponse.from( + memberProfileService.presignAvatar(memberId, request.contentType(), request.size())); } @GetMapping("/me/social-accounts") public List socialAccounts(@AuthenticationPrincipal Long memberId) { + return memberProfileService.getSocialAccounts(memberId).stream() .map(SocialAccountResponse::from) .toList(); @@ -100,7 +113,7 @@ public List socialAccounts(@AuthenticationPrincipal Long @ResponseStatus(HttpStatus.NO_CONTENT) public void unlinkSocial( @AuthenticationPrincipal Long memberId, - @CurrentSecurityContext(expression = "authentication.details") Provider currentProvider, + @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider, @PathVariable String provider ) { memberProfileService.unlinkSocial(memberId, Provider.from(provider), currentProvider); diff --git a/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java b/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java index d840c52..122c0df 100644 --- a/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java +++ b/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java @@ -1,14 +1,10 @@ package com.nalssilog.member.application; import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.security.SecretFingerprint; 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; @@ -27,7 +23,6 @@ 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 @@ -42,6 +37,7 @@ public class FeedbackRateLimiter { public void check(Long memberId, String remoteAddress) { String actor = actor(memberId, remoteAddress); Long count; + try { count = redisTemplate.execute( INCREMENT_SCRIPT, @@ -51,6 +47,7 @@ public void check(Long memberId, String remoteAddress) { // 피드백 저장 자체보다 보조 보호장치 장애의 영향이 커지지 않게 제한기만 fail-open 한다. log.warn("feedback.rate_limit_unavailable actor={} reason={}", safeActor(actor), exception.getClass().getSimpleName()); + return; } @@ -62,29 +59,21 @@ public void check(Long memberId, String remoteAddress) { String actor(Long memberId, String remoteAddress) { if (memberId != null) { + return "member:" + memberId; } String address = StringUtils.hasText(remoteAddress) ? remoteAddress.strip() : "unknown"; - return "guest:" + hmac(address); + + return "guest:" + SecretFingerprint.hmacSha256(properties.ipHmacSecret(), 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/MemberAccountService.java b/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java index 5066ee2..e6f8b71 100644 --- a/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java +++ b/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java @@ -28,16 +28,20 @@ public class MemberAccountService { /** 소셜 인증 결과 분기(생성·병합 안 함). 가입된 소셜=EXISTING, 이메일로 기존 회원 있으면 LINK_REQUIRED, 없으면 NEW. */ @Transactional public SocialLoginResult resolveSocialLogin(Provider provider, String providerUserId, String email) { - Optional linked = socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId); + Optional linked = socialAccountRepository.findByProviderAndProviderUserId( + provider, + providerUserId); if (linked.isPresent()) { SocialAccount account = linked.get(); + account.touchLogin(); return SocialLoginResult.existing(account.getMember().getId(), account.getMember().getStatus()); } if (email == null || email.isBlank()) { + return SocialLoginResult.newMember(email); } @@ -54,6 +58,7 @@ public MemberInfo linkSocial(Long targetMemberId, Provider provider, String prov } Member member = memberRepository.getMember(targetMemberId); + socialAccountRepository.save(SocialAccount.link(member, provider, providerUserId, email)); return memberRepository.getMemberInfo(targetMemberId); @@ -71,10 +76,22 @@ public void withdraw(Long memberId) { } public MemberInfo getMemberInfo(Long memberId) { + return memberRepository.getMemberInfo(memberId); } public Optional findMemberInfo(Long memberId) { + return memberRepository.findMemberInfo(memberId); } + + public Optional findMemberInfo( + Provider provider, + String providerUserId + ) { + + return socialAccountRepository + .findByProviderAndProviderUserId(provider, providerUserId) + .map(account -> memberRepository.getMemberInfo(account.getMember().getId())); + } } diff --git a/member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java b/member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java index 04f1d24..ca07206 100644 --- a/member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java +++ b/member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java @@ -1,12 +1,8 @@ package com.nalssilog.member.config; -import jakarta.servlet.ServletRequest; -import jakarta.servlet.ServletRequestWrapper; +import com.nalssilog.common.web.TrustedProxyChain; 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를 해석한다. @@ -16,89 +12,14 @@ @Component public class TrustedProxyClientIpResolver { - private static final int MAX_WRAPPER_DEPTH = 16; - - private final List trustedProxies; + private final TrustedProxyChain trustedProxyChain; public TrustedProxyClientIpResolver(FeedbackRateLimitProperties properties) { - this.trustedProxies = properties.trustedProxies().stream() - .map(IpAddressMatcher::new) - .toList(); + this.trustedProxyChain = new TrustedProxyChain(properties.trustedProxies()); } 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; + return trustedProxyChain.resolve(request); } } diff --git a/member/src/main/java/com/nalssilog/member/domain/Provider.java b/member/src/main/java/com/nalssilog/member/domain/Provider.java index 11bb2a6..202b809 100644 --- a/member/src/main/java/com/nalssilog/member/domain/Provider.java +++ b/member/src/main/java/com/nalssilog/member/domain/Provider.java @@ -3,9 +3,10 @@ import java.util.Locale; public enum Provider { - GOOGLE, KAKAO, NAVER; + GOOGLE, KAKAO, NAVER, APPLE; public static Provider from(String registrationId) { + return valueOf(registrationId.toUpperCase(Locale.ROOT)); } } diff --git a/report/src/main/java/com/nalssilog/report/config/ReportActorResolver.java b/report/src/main/java/com/nalssilog/report/config/ReportActorResolver.java index 327b78d..172b5c6 100644 --- a/report/src/main/java/com/nalssilog/report/config/ReportActorResolver.java +++ b/report/src/main/java/com/nalssilog/report/config/ReportActorResolver.java @@ -1,5 +1,6 @@ package com.nalssilog.report.config; +import com.nalssilog.common.security.VerifiedRequestCredentials; import com.nalssilog.report.application.dto.ReportActor; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -20,17 +21,33 @@ public class ReportActorResolver { public ReportActor resolveForWrite(Long memberId, HttpServletRequest request, HttpServletResponse response) { if (memberId != null) { + return ReportActor.member(memberId); } + var mobileGuest = VerifiedRequestCredentials.guestAnonymousKey(request); + + if (mobileGuest.isPresent()) { + + return ReportActor.anonymous(mobileGuest.get()); + } + return ReportActor.anonymous(anonymousIdManager.getOrIssue(request, response)); } public ReportActor resolveForRead(Long memberId, HttpServletRequest request) { if (memberId != null) { + return ReportActor.member(memberId); } + var mobileGuest = VerifiedRequestCredentials.guestAnonymousKey(request); + + if (mobileGuest.isPresent()) { + + return ReportActor.anonymous(mobileGuest.get()); + } + return anonymousIdManager.read(request) .map(ReportActor::anonymous) .orElse(null); @@ -46,10 +63,16 @@ public List resolveForOwnership(Long memberId, HttpServletRequest r if (memberId != null) { actors.add(ReportActor.member(memberId)); } - anonymousIdManager.read(request) + VerifiedRequestCredentials.guestAnonymousKey(request) .map(ReportActor::anonymous) .ifPresent(actors::add); + if (!VerifiedRequestCredentials.hasNonCookieCredential(request)) { + anonymousIdManager.read(request) + .map(ReportActor::anonymous) + .ifPresent(actors::add); + } + return List.copyOf(actors); } } diff --git a/report/src/test/java/com/nalssilog/report/config/ReportActorResolverTest.java b/report/src/test/java/com/nalssilog/report/config/ReportActorResolverTest.java new file mode 100644 index 0000000..6f9157a --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/config/ReportActorResolverTest.java @@ -0,0 +1,49 @@ +package com.nalssilog.report.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; + +import com.nalssilog.common.security.VerifiedRequestCredentials; +import com.nalssilog.report.application.dto.ReportActor; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@SuppressWarnings("java:S5960") +class ReportActorResolverTest { + + private final AnonymousIdManager anonymousIdManager = + mock(AnonymousIdManager.class); + private final ReportActorResolver resolver = + new ReportActorResolver(anonymousIdManager); + + @Test + void verifiedMobileGuestIsUsedInsteadOfTheWebCookieFallback() { + MockHttpServletRequest request = new MockHttpServletRequest(); + + VerifiedRequestCredentials.markGuest(request, "mobile-anonymous-key"); + + ReportActor actor = resolver.resolveForWrite( + null, + request, + new MockHttpServletResponse()); + + assertThat(actor) + .isEqualTo(ReportActor.anonymous("mobile-anonymous-key")); + verifyNoInteractions(anonymousIdManager); + } + + @Test + void authenticatedMemberCanRetainGuestOwnershipCandidate() { + MockHttpServletRequest request = new MockHttpServletRequest(); + + VerifiedRequestCredentials.markGuest(request, "mobile-anonymous-key"); + + assertThat(resolver.resolveForOwnership(7L, request)) + .containsExactly( + ReportActor.member(7L), + ReportActor.anonymous("mobile-anonymous-key")); + verifyNoInteractions(anonymousIdManager); + } +} diff --git a/storage/src/main/java/com/nalssilog/storage/StorageConfig.java b/storage/src/main/java/com/nalssilog/storage/StorageConfig.java index c07a76e..b5a0cd2 100644 --- a/storage/src/main/java/com/nalssilog/storage/StorageConfig.java +++ b/storage/src/main/java/com/nalssilog/storage/StorageConfig.java @@ -23,7 +23,7 @@ public S3Presigner s3Presigner(StorageProperties properties) { StorageProperties.R2 r2 = properties.r2(); return S3Presigner.builder() - .endpointOverride(URI.create(r2.endpoint())) + .endpointOverride(URI.create(r2.effectivePresignEndpoint())) .region(Region.of("auto")) .credentialsProvider(credentials(r2)) .serviceConfiguration(S3Configuration.builder() @@ -48,6 +48,7 @@ public S3Client s3Client(StorageProperties properties) { } private StaticCredentialsProvider credentials(StorageProperties.R2 r2) { + return StaticCredentialsProvider.create(AwsBasicCredentials.create(r2.accessKey(), r2.secretKey())); } } diff --git a/storage/src/main/java/com/nalssilog/storage/StorageProperties.java b/storage/src/main/java/com/nalssilog/storage/StorageProperties.java index 4ff3658..87aef9d 100644 --- a/storage/src/main/java/com/nalssilog/storage/StorageProperties.java +++ b/storage/src/main/java/com/nalssilog/storage/StorageProperties.java @@ -15,6 +15,24 @@ public record StorageProperties( boolean verifyUpload ) { - public record R2(String endpoint, String accessKey, String secretKey, String bucket) { + /** + * {@code endpoint} is used by the backend for HEAD/delete calls. When the backend runs inside + * Docker, {@code presignEndpoint} can point at the LAN-visible host used in the signed URL. + * If omitted, both operations use {@code endpoint}. + */ + public record R2( + String endpoint, + String presignEndpoint, + String accessKey, + String secretKey, + String bucket + ) { + + public String effectivePresignEndpoint() { + + return presignEndpoint == null || presignEndpoint.isBlank() + ? endpoint + : presignEndpoint; + } } } From 64fd28a97a40ba02c14380b715bd28a63d90c9e5 Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Thu, 30 Jul 2026 19:10:27 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=205=EA=B0=9C=20=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=EC=99=80=20=EC=9D=B8=EA=B8=B0=20?= =?UTF-8?q?=EC=88=9C=EC=9C=84=20=EC=8A=A4=EB=83=85=EC=83=B7=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20-=20=EA=B2=80=EC=83=89=EA=B3=BC=20=EC=A6=90?= =?UTF-8?q?=EA=B2=A8=EC=B0=BE=EA=B8=B0=EB=A5=BC=20=EC=A0=84=EC=B2=B4=20?= =?UTF-8?q?=EA=B0=9C=EC=88=98=EC=99=80=20=ED=8E=98=EC=9D=B4=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EB=A5=BC=20=ED=8F=AC=ED=95=A8=ED=95=98=EB=8A=94=20Pag?= =?UTF-8?q?eResponse=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20-=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EA=B3=BC=20=EC=A6=90=EA=B2=A8=EC=B0=BE=EA=B8=B0?= =?UTF-8?q?=EC=9D=98=20=ED=8E=98=EC=9D=B4=EC=A7=80=20=ED=81=AC=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=84=9C=EB=B2=84=EC=97=90=EC=84=9C=205=EA=B0=9C?= =?UTF-8?q?=EB=A1=9C=20=EA=B3=A0=EC=A0=95=20-=20=ED=95=9C=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20=EC=A7=80=EC=97=AD=EB=AA=85=20prefix=20=EA=B2=80?= =?UTF-8?q?=EC=83=89=EA=B3=BC=20=EA=B3=BC=EA=B1=B0=20=EC=A7=80=EC=97=AD?= =?UTF-8?q?=EB=AA=85=20=EC=A0=95=EA=B7=9C=ED=99=94=EB=A5=BC=20=EC=A7=80?= =?UTF-8?q?=EC=9B=90=20-=20=EA=B2=80=EC=83=89=20=EB=82=B4=EC=9A=A9?= =?UTF-8?q?=EA=B3=BC=20=EC=A0=84=EC=B2=B4=20=EA=B0=9C=EC=88=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20QueryDSL=EB=A1=9C=20=EA=B5=AC=ED=98=84=20-?= =?UTF-8?q?=20=EC=A6=90=EA=B2=A8=EC=B0=BE=EA=B8=B0=20=EB=8B=A8=EC=88=9C=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=A1=B0=ED=9A=8C=EB=8A=94=20Spr?= =?UTF-8?q?ing=20Data=20JPA=20=ED=8C=8C=EC=83=9D=20=EC=BF=BC=EB=A6=AC?= =?UTF-8?q?=EB=A1=9C=20=EC=B2=98=EB=A6=AC=20-=20=EC=B5=9C=EA=B7=BC=207?= =?UTF-8?q?=EC=9D=BC=20=EA=B3=A0=EC=9C=A0=20=EC=A0=9C=EB=B3=B4=EC=9E=90=20?= =?UTF-8?q?=EC=88=98=EB=A5=BC=20=EC=9A=B0=EC=84=A0=ED=95=98=EB=8A=94=20?= =?UTF-8?q?=EC=9D=B8=EA=B8=B0=20=EC=A7=80=EC=97=AD=20TOP=2020=EC=9D=84=20?= =?UTF-8?q?=EC=82=B0=EC=A0=95=20-=20=EC=A0=9C=EB=B3=B4=20=EC=88=98,=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=20=EC=A0=9C=EB=B3=B4=20=EC=8B=9C=EA=B0=81?= =?UTF-8?q?=EA=B3=BC=20=EC=A7=80=EC=97=AD=20ID=EB=A5=BC=20=ED=9B=84?= =?UTF-8?q?=EC=88=9C=EC=9C=84=20=EC=A0=95=EB=A0=AC=20=EA=B8=B0=EC=A4=80?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A0=81=EC=9A=A9=20-=20=EC=9D=B8?= =?UTF-8?q?=EA=B8=B0=20=EC=88=9C=EC=9C=84=EB=A5=BC=2010=EB=B6=84=20?= =?UTF-8?q?=EB=8B=A8=EC=9C=84=20DB=20=EC=8A=A4=EB=83=85=EC=83=B7=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=80=EC=9E=A5=20-=20=EA=B3=84=EC=82=B0=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81,=20=EC=A7=91=EA=B3=84=20=EA=B5=AC=EA=B0=84?= =?UTF-8?q?=EA=B3=BC=20=EC=95=8C=EA=B3=A0=EB=A6=AC=EC=A6=98=20=EB=B2=84?= =?UTF-8?q?=EC=A0=84=EC=9D=84=20=EA=B8=B0=EB=A1=9D=20-=20=EC=9D=B4?= =?UTF-8?q?=EC=A0=84=20=EC=88=9C=EC=9C=84,=20=EB=B3=80=ED=99=94=EB=9F=89?= =?UTF-8?q?=EA=B3=BC=20NEW=C2=B7UP=C2=B7DOWN=C2=B7SAME=20=EC=83=81?= =?UTF-8?q?=ED=83=9C=EB=A5=BC=20=EC=A0=80=EC=9E=A5=20-=20=EC=9D=B8?= =?UTF-8?q?=EA=B8=B0=20TOP=2020=EC=9D=84=20=EB=8F=99=EC=9D=BC=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=EC=9D=98=20=EC=9B=90=EC=9E=90=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=EC=9C=BC=EB=A1=9C=20=EC=A0=9C=EA=B3=B5=20-=20?= =?UTF-8?q?=ED=94=84=EB=A1=A0=ED=8A=B8=EA=B0=80=205=EA=B0=9C=EC=94=A9=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=ED=95=98=EB=8F=84=EB=A1=9D=20pageSize?= =?UTF-8?q?=EC=99=80=20totalPages=EB=A5=BC=20=EC=A0=9C=EA=B3=B5=20-=20?= =?UTF-8?q?=EC=A7=80=EC=97=AD=20=EA=B2=80=EC=83=89=20=EB=B0=8F=20=EC=9D=B8?= =?UTF-8?q?=EA=B8=B0=20=EC=88=9C=EC=9C=84=EC=9A=A9=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=EA=B3=BC=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V10__add_popular_location_snapshots.sql | 55 +++++ .../V9__optimize_location_search.sql | 17 ++ .../common/response/PageResponse.java | 69 ++++++ .../common/response/PageResponseTest.java | 35 +++ .../location/api/LocationController.java | 28 ++- .../api/LocationFavoriteController.java | 22 +- .../api/dto/PopularLocationsResponse.java | 74 ++++++ .../application/LocationFavoriteService.java | 27 ++- .../location/application/LocationService.java | 98 +++++--- .../application/PopularLocationSource.java | 8 +- .../dto/PopularLocationSnapshotData.java | 34 +++ .../dto/PopularLocationSnapshotInfo.java | 34 +++ .../location/config/LocationProperties.java | 13 - .../config/PopularLocationProperties.java | 32 +++ .../location/domain/PopularRankMovement.java | 8 + .../LocationFavoriteJpaRepository.java | 18 -- .../LocationFavoriteRepository.java | 47 +--- .../repository/LocationJpaRepository.java | 3 - .../repository/LocationRepository.java | 156 ++++++++---- .../location/api/LocationControllerTest.java | 106 ++++++++ .../api/LocationFavoriteControllerTest.java | 47 ++++ .../api/dto/PopularLocationsResponseTest.java | 51 ++++ .../LocationFavoriteServiceTest.java | 60 +++++ .../application/LocationServiceTest.java | 167 +++++++++---- .../PopularLocationSnapshotScheduler.java | 20 ++ .../PopularLocationSnapshotService.java | 184 ++++++++++++++ .../ReportPopularLocationSource.java | 16 +- .../dto/PopularLocationAggregate.java | 14 ++ .../report/domain/PopularLocationRank.java | 96 ++++++++ .../domain/PopularLocationSnapshot.java | 63 +++++ .../PopularLocationRankJpaRepository.java | 11 + .../PopularLocationSnapshotJpaRepository.java | 11 + .../repository/WeatherReportRepository.java | 55 ++++- .../PopularLocationSnapshotServiceTest.java | 229 ++++++++++++++++++ .../ReportPopularLocationSourceTest.java | 38 +++ 35 files changed, 1698 insertions(+), 248 deletions(-) create mode 100644 app/src/main/resources/db/migration/V10__add_popular_location_snapshots.sql create mode 100644 app/src/main/resources/db/migration/V9__optimize_location_search.sql create mode 100644 common/src/main/java/com/nalssilog/common/response/PageResponse.java create mode 100644 common/src/test/java/com/nalssilog/common/response/PageResponseTest.java create mode 100644 location/src/main/java/com/nalssilog/location/api/dto/PopularLocationsResponse.java create mode 100644 location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotData.java create mode 100644 location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotInfo.java delete mode 100644 location/src/main/java/com/nalssilog/location/config/LocationProperties.java create mode 100644 location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java create mode 100644 location/src/main/java/com/nalssilog/location/domain/PopularRankMovement.java delete mode 100644 location/src/main/java/com/nalssilog/location/repository/LocationFavoriteJpaRepository.java create mode 100644 location/src/test/java/com/nalssilog/location/api/LocationControllerTest.java create mode 100644 location/src/test/java/com/nalssilog/location/api/LocationFavoriteControllerTest.java create mode 100644 location/src/test/java/com/nalssilog/location/api/dto/PopularLocationsResponseTest.java create mode 100644 location/src/test/java/com/nalssilog/location/application/LocationFavoriteServiceTest.java create mode 100644 report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotScheduler.java create mode 100644 report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java create mode 100644 report/src/main/java/com/nalssilog/report/application/dto/PopularLocationAggregate.java create mode 100644 report/src/main/java/com/nalssilog/report/domain/PopularLocationRank.java create mode 100644 report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshot.java create mode 100644 report/src/main/java/com/nalssilog/report/repository/PopularLocationRankJpaRepository.java create mode 100644 report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotJpaRepository.java create mode 100644 report/src/test/java/com/nalssilog/report/application/PopularLocationSnapshotServiceTest.java create mode 100644 report/src/test/java/com/nalssilog/report/application/ReportPopularLocationSourceTest.java diff --git a/app/src/main/resources/db/migration/V10__add_popular_location_snapshots.sql b/app/src/main/resources/db/migration/V10__add_popular_location_snapshots.sql new file mode 100644 index 0000000..29ae429 --- /dev/null +++ b/app/src/main/resources/db/migration/V10__add_popular_location_snapshots.sql @@ -0,0 +1,55 @@ +CREATE TABLE popular_location_snapshot ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + calculated_at timestamp(6) with time zone NOT NULL, + window_started_at timestamp(6) with time zone NOT NULL, + window_ended_at timestamp(6) with time zone NOT NULL, + ranking_limit integer NOT NULL, + algorithm_version character varying(40) NOT NULL, + CONSTRAINT chk_popular_location_snapshot_window + CHECK (window_started_at < window_ended_at), + CONSTRAINT chk_popular_location_snapshot_limit + CHECK (ranking_limit BETWEEN 1 AND 20) +); + +CREATE INDEX idx_popular_location_snapshot_calculated + ON popular_location_snapshot (calculated_at); + +CREATE TABLE popular_location_rank ( + id bigint GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + snapshot_id bigint NOT NULL, + location_id bigint NOT NULL, + ranking_position integer NOT NULL, + previous_ranking_position integer, + rank_change integer, + movement character varying(10) NOT NULL, + unique_reporter_count bigint NOT NULL, + report_count bigint NOT NULL, + latest_report_at timestamp(6) with time zone NOT NULL, + CONSTRAINT fk_popular_location_rank_snapshot + FOREIGN KEY (snapshot_id) + REFERENCES popular_location_snapshot (id) + ON DELETE CASCADE, + CONSTRAINT fk_popular_location_rank_location + FOREIGN KEY (location_id) + REFERENCES location (id), + CONSTRAINT uk_popular_location_rank_snapshot_position + UNIQUE (snapshot_id, ranking_position), + CONSTRAINT uk_popular_location_rank_snapshot_location + UNIQUE (snapshot_id, location_id), + CONSTRAINT chk_popular_location_rank_position + CHECK (ranking_position BETWEEN 1 AND 20), + CONSTRAINT chk_popular_location_previous_rank + CHECK (previous_ranking_position IS NULL + OR previous_ranking_position BETWEEN 1 AND 20), + CONSTRAINT chk_popular_location_rank_movement + CHECK (movement IN ('NEW', 'UP', 'DOWN', 'SAME')), + CONSTRAINT chk_popular_location_rank_counts + CHECK (unique_reporter_count >= 0 AND report_count >= 1) +); + +CREATE INDEX idx_popular_location_rank_snapshot + ON popular_location_rank (snapshot_id, ranking_position); + +CREATE INDEX idx_weather_report_popular_window + ON weather_report (created_at DESC, location_id) + INCLUDE (id, author_member_id, author_anonymous_key); diff --git a/app/src/main/resources/db/migration/V9__optimize_location_search.sql b/app/src/main/resources/db/migration/V9__optimize_location_search.sql new file mode 100644 index 0000000..ed3366a --- /dev/null +++ b/app/src/main/resources/db/migration/V9__optimize_location_search.sql @@ -0,0 +1,17 @@ +-- 한 글자 검색은 prefix 인덱스로, 두 글자 이상의 부분 검색은 trigram 인덱스로 처리한다. +CREATE EXTENSION IF NOT EXISTS pg_trgm; + +CREATE INDEX idx_location_sido_prefix + ON location (sido text_pattern_ops); + +CREATE INDEX idx_location_sigungu_prefix + ON location (sigungu text_pattern_ops); + +CREATE INDEX idx_location_dong_prefix + ON location (dong text_pattern_ops); + +CREATE INDEX idx_location_label_prefix + ON location ((sido || ' ' || sigungu || ' ' || dong) text_pattern_ops); + +CREATE INDEX idx_location_label_trgm + ON location USING gin ((sido || ' ' || sigungu || ' ' || dong) gin_trgm_ops); diff --git a/common/src/main/java/com/nalssilog/common/response/PageResponse.java b/common/src/main/java/com/nalssilog/common/response/PageResponse.java new file mode 100644 index 0000000..1fb58a5 --- /dev/null +++ b/common/src/main/java/com/nalssilog/common/response/PageResponse.java @@ -0,0 +1,69 @@ +package com.nalssilog.common.response; + +import java.util.List; +import java.util.function.Function; +import org.springframework.data.domain.Page; + +/** + * 전체 개수와 페이지 수를 포함하는 번호 기반 페이지 응답. + */ +public record PageResponse( + List items, + int page, + int size, + long totalElements, + int totalPages, + boolean hasPrevious, + boolean hasNext +) { + + public PageResponse { + items = List.copyOf(items); + } + + public static PageResponse from(Page source) { + + return new PageResponse<>( + source.getContent(), + source.getNumber(), + source.getSize(), + source.getTotalElements(), + source.getTotalPages(), + source.hasPrevious(), + source.hasNext()); + } + + public static PageResponse of( + List items, + int page, + int size, + long totalElements + ) { + if (page < 0 || size < 1 || totalElements < 0) { + throw new IllegalArgumentException("invalid page metadata"); + } + + int totalPages = Math.toIntExact(Math.ceilDiv(totalElements, size)); + + return new PageResponse<>( + items, + page, + size, + totalElements, + totalPages, + page > 0 && totalPages > 0, + page + 1 < totalPages); + } + + public PageResponse map(Function mapper) { + + return new PageResponse<>( + items.stream().map(mapper).toList(), + page, + size, + totalElements, + totalPages, + hasPrevious, + hasNext); + } +} diff --git a/common/src/test/java/com/nalssilog/common/response/PageResponseTest.java b/common/src/test/java/com/nalssilog/common/response/PageResponseTest.java new file mode 100644 index 0000000..1095adb --- /dev/null +++ b/common/src/test/java/com/nalssilog/common/response/PageResponseTest.java @@ -0,0 +1,35 @@ +package com.nalssilog.common.response; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class PageResponseTest { + + @Test + void calculatesNumberedPageMetadataFromTotalElements() { + PageResponse page = PageResponse.of( + List.of("여섯", "일곱", "여덟", "아홉", "열"), + 1, + 5, + 12); + + assertThat(page.page()).isEqualTo(1); + assertThat(page.size()).isEqualTo(5); + assertThat(page.totalElements()).isEqualTo(12); + assertThat(page.totalPages()).isEqualTo(3); + assertThat(page.hasPrevious()).isTrue(); + assertThat(page.hasNext()).isTrue(); + } + + @Test + void returnsZeroPagesForAnEmptyResult() { + PageResponse page = PageResponse.of(List.of(), 0, 5, 0); + + assertThat(page.totalPages()).isZero(); + assertThat(page.hasPrevious()).isFalse(); + assertThat(page.hasNext()).isFalse(); + } +} diff --git a/location/src/main/java/com/nalssilog/location/api/LocationController.java b/location/src/main/java/com/nalssilog/location/api/LocationController.java index ebcb865..354ce79 100644 --- a/location/src/main/java/com/nalssilog/location/api/LocationController.java +++ b/location/src/main/java/com/nalssilog/location/api/LocationController.java @@ -1,9 +1,12 @@ package com.nalssilog.location.api; +import com.nalssilog.common.response.PageResponse; import com.nalssilog.location.api.dto.LocationResponse; +import com.nalssilog.location.api.dto.PopularLocationsResponse; import com.nalssilog.location.application.LocationService; +import jakarta.validation.constraints.Min; import jakarta.validation.constraints.NotBlank; -import java.util.List; +import jakarta.validation.constraints.Size; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -19,29 +22,32 @@ public class LocationController { private final LocationService locationService; @GetMapping - public List search(@RequestParam @NotBlank String keyword) { - return locationService.search(keyword).stream() - .map(LocationResponse::from) - .toList(); + public PageResponse search( + @RequestParam @NotBlank @Size(max = 50) String keyword, + @RequestParam(defaultValue = "0") @Min(0) int page + ) { + + return locationService.search(keyword, page).map(LocationResponse::from); } @GetMapping("/reverse-geocode") public LocationResponse reverseGeocode( - @RequestParam double lat, - @RequestParam double lng + @RequestParam double lat, + @RequestParam double lng ) { + return LocationResponse.from(locationService.reverseGeocode(lat, lng)); } @GetMapping("/popular") - public List popular() { - return locationService.getPopular().stream() - .map(LocationResponse::from) - .toList(); + public PopularLocationsResponse popular() { + + return PopularLocationsResponse.from(locationService.getPopular()); } @GetMapping("/{id}") public LocationResponse detail(@PathVariable Long id) { + return LocationResponse.from(locationService.getLocation(id)); } } diff --git a/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java b/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java index 62f0f71..2d3826c 100644 --- a/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java +++ b/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java @@ -1,10 +1,11 @@ package com.nalssilog.location.api; +import com.nalssilog.common.response.PageResponse; import com.nalssilog.location.api.dto.FavoriteRequest; import com.nalssilog.location.api.dto.LocationResponse; import com.nalssilog.location.application.LocationFavoriteService; import jakarta.validation.Valid; -import java.util.List; +import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -14,6 +15,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; @@ -25,17 +27,19 @@ public class LocationFavoriteController { private final LocationFavoriteService locationFavoriteService; @GetMapping - public List myFavorites(@AuthenticationPrincipal Long memberId) { - return locationFavoriteService.listFavorites(memberId).stream() - .map(LocationResponse::from) - .toList(); + public PageResponse myFavorites( + @AuthenticationPrincipal Long memberId, + @RequestParam(defaultValue = "0") @Min(0) int page + ) { + + return locationFavoriteService.listFavorites(memberId, page).map(LocationResponse::from); } @PostMapping @ResponseStatus(HttpStatus.CREATED) public void addFavorite( - @AuthenticationPrincipal Long memberId, - @Valid @RequestBody FavoriteRequest request + @AuthenticationPrincipal Long memberId, + @Valid @RequestBody FavoriteRequest request ) { locationFavoriteService.addFavorite(memberId, request.locationId()); } @@ -43,8 +47,8 @@ public void addFavorite( @DeleteMapping("/{locationId}") @ResponseStatus(HttpStatus.NO_CONTENT) public void removeFavorite( - @AuthenticationPrincipal Long memberId, - @PathVariable Long locationId + @AuthenticationPrincipal Long memberId, + @PathVariable Long locationId ) { locationFavoriteService.removeFavorite(memberId, locationId); } diff --git a/location/src/main/java/com/nalssilog/location/api/dto/PopularLocationsResponse.java b/location/src/main/java/com/nalssilog/location/api/dto/PopularLocationsResponse.java new file mode 100644 index 0000000..4e26cdb --- /dev/null +++ b/location/src/main/java/com/nalssilog/location/api/dto/PopularLocationsResponse.java @@ -0,0 +1,74 @@ +package com.nalssilog.location.api.dto; + +import com.nalssilog.location.application.dto.PopularLocationSnapshotInfo; +import com.nalssilog.location.domain.PopularRankMovement; +import java.time.Instant; +import java.util.List; + +/** + * 동일 계산 시점의 인기 지역 top 20 전체 응답. + * 프론트는 {@code pageSize} 단위로 나누어 표시한다. + */ +public record PopularLocationsResponse( + String snapshotId, + Instant calculatedAt, + Instant windowStartedAt, + Instant windowEndedAt, + String algorithmVersion, + int pageSize, + long totalElements, + int totalPages, + List items +) { + + private static final int PAGE_SIZE = 5; + + public PopularLocationsResponse { + items = List.copyOf(items); + } + + public static PopularLocationsResponse from(PopularLocationSnapshotInfo snapshot) { + List items = snapshot.items().stream() + .map(Ranking::from) + .toList(); + int totalPages = items.isEmpty() + ? 0 + : (items.size() + PAGE_SIZE - 1) / PAGE_SIZE; + + return new PopularLocationsResponse( + String.valueOf(snapshot.snapshotId()), + snapshot.calculatedAt(), + snapshot.windowStartedAt(), + snapshot.windowEndedAt(), + snapshot.algorithmVersion(), + PAGE_SIZE, + items.size(), + totalPages, + items); + } + + public record Ranking( + int rank, + Integer previousRank, + Integer rankChange, + PopularRankMovement movement, + long uniqueReporterCount, + long reportCount, + Instant latestReportAt, + LocationResponse location + ) { + + private static Ranking from(PopularLocationSnapshotInfo.Item item) { + + return new Ranking( + item.rank(), + item.previousRank(), + item.rankChange(), + item.movement(), + item.uniqueReporterCount(), + item.reportCount(), + item.latestReportAt(), + LocationResponse.from(item.location())); + } + } +} diff --git a/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java b/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java index 3006977..7866ec6 100644 --- a/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java +++ b/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java @@ -1,11 +1,14 @@ package com.nalssilog.location.application; +import com.nalssilog.common.response.PageResponse; import com.nalssilog.location.application.dto.LocationInfo; import com.nalssilog.location.domain.LocationFavorite; import com.nalssilog.location.repository.LocationFavoriteRepository; import com.nalssilog.location.repository.LocationRepository; import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +20,8 @@ @Transactional(readOnly = true) public class LocationFavoriteService { + private static final int PAGE_SIZE = 5; + private final LocationFavoriteRepository locationFavoriteRepository; private final LocationRepository locationRepository; @@ -24,19 +29,29 @@ public class LocationFavoriteService { public void addFavorite(Long memberId, Long locationId) { locationRepository.getById(locationId); - if (!locationFavoriteRepository.exists(memberId, locationId)) { + if (!locationFavoriteRepository.existsByMemberIdAndLocationId(memberId, locationId)) { locationFavoriteRepository.save(LocationFavorite.of(memberId, locationId)); } } @Transactional public void removeFavorite(Long memberId, Long locationId) { - locationFavoriteRepository.delete(memberId, locationId); + locationFavoriteRepository.deleteByMemberIdAndLocationId(memberId, locationId); } - public List listFavorites(Long memberId) { - List locationIds = locationFavoriteRepository.findFavoriteLocationIds(memberId); - - return locationRepository.findByIds(locationIds); + public PageResponse listFavorites(Long memberId, int page) { + Page favorites = + locationFavoriteRepository.findAllByMemberIdOrderByCreatedAtDescIdDesc( + memberId, + PageRequest.of(page, PAGE_SIZE)); + List favoriteIds = favorites.getContent().stream() + .map(LocationFavorite::getLocationId) + .toList(); + + return PageResponse.of( + locationRepository.findByIds(favoriteIds), + page, + PAGE_SIZE, + favorites.getTotalElements()); } } diff --git a/location/src/main/java/com/nalssilog/location/application/LocationService.java b/location/src/main/java/com/nalssilog/location/application/LocationService.java index 091207f..e8bb4e9 100644 --- a/location/src/main/java/com/nalssilog/location/application/LocationService.java +++ b/location/src/main/java/com/nalssilog/location/application/LocationService.java @@ -1,17 +1,21 @@ package com.nalssilog.location.application; import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.common.response.PageResponse; import com.nalssilog.location.application.dto.LocationInfo; +import com.nalssilog.location.application.dto.PopularLocationSnapshotData; +import com.nalssilog.location.application.dto.PopularLocationSnapshotInfo; import com.nalssilog.location.client.KakaoMapClient; import com.nalssilog.location.client.KakaoRegion; -import com.nalssilog.location.config.LocationProperties; import com.nalssilog.location.domain.LocationErrorCode; import com.nalssilog.location.repository.LocationRepository; -import java.util.ArrayList; -import java.util.HashSet; import java.util.List; -import java.util.Set; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -24,23 +28,32 @@ @Transactional(readOnly = true) public class LocationService { - private static final int POPULAR_SIZE = 5; + private static final int PAGE_SIZE = 5; private static final String FORMER_JEONBUK = "전라북도"; private static final String CURRENT_JEONBUK = "전북특별자치도"; private static final String FORMER_JEONNAM = "전라남도"; private static final String FORMER_GWANGJU = "광주광역시"; private static final String CURRENT_JEONNAM_GWANGJU = "전남광주통합특별시"; + private static final String FORMER_JEOLLA = "전라도"; + private static final String FORMER_JEOLLA_SHORT = "전라"; + private static final String CURRENT_JEOLLA_PREFIX = "전"; private final LocationRepository locationRepository; private final PopularLocationSource popularLocationSource; - private final LocationProperties properties; private final KakaoMapClient kakaoMapClient; - public List search(String keyword) { - return locationRepository.searchByKeyword(normalizeLegacyRegionName(keyword.strip())); + public PageResponse search(String keyword, int page) { + String normalizedKeyword = normalizeLegacyRegionName( + keyword.strip().replaceAll("\\s+", " ")); + Page result = locationRepository.searchByKeyword( + normalizedKeyword, + PageRequest.of(page, PAGE_SIZE)); + + return PageResponse.from(result); } public LocationInfo getLocation(Long locationId) { + return locationRepository.getById(locationId); } @@ -48,6 +61,7 @@ public LocationInfo getLocation(Long locationId) { * 여러 지역 일괄 조회. 없는 id 는 조용히 제외한다(제보 목록의 지역 enrich 배치용). */ public List getLocations(List locationIds) { + return locationRepository.findByIds(locationIds); } @@ -55,33 +69,30 @@ public List getLocations(List locationIds) { @Transactional(propagation = Propagation.NOT_SUPPORTED) public LocationInfo reverseGeocode(double latitude, double longitude) { validateCoordinates(latitude, longitude); + KakaoRegion region = kakaoMapClient.reverseGeocode(latitude, longitude); return locationRepository.findOrCreate(region); } - /** 인기 동네 top5. 최근 제보 지역을 우선하고 부족한 자리는 설정된 대표 지역으로 중복 없이 채운다. */ - public List getPopular() { - List popularIds = popularLocationSource.topLocationIds(POPULAR_SIZE); - List result = new ArrayList<>(locationRepository.findByIds(popularIds)); - - if (result.size() >= POPULAR_SIZE) { - return List.copyOf(result.subList(0, POPULAR_SIZE)); - } - - Set addedIds = new HashSet<>(); - result.forEach(location -> addedIds.add(location.id())); - - for (LocationInfo featured : locationRepository.findByAdminCodes(properties.featuredAdminCodes())) { - if (addedIds.add(featured.id())) { - result.add(featured); - } - if (result.size() == POPULAR_SIZE) { - break; - } - } - - return List.copyOf(result); + public PopularLocationSnapshotInfo getPopular() { + PopularLocationSnapshotData snapshot = popularLocationSource.latestSnapshot(); + List locationIds = snapshot.rankings().stream() + .map(PopularLocationSnapshotData.Rank::locationId) + .toList(); + Map locationsById = locationRepository.findByIds(locationIds).stream() + .collect(Collectors.toMap(LocationInfo::id, Function.identity())); + List items = snapshot.rankings().stream() + .map(rank -> popularItem(rank, locationsById)) + .toList(); + + return new PopularLocationSnapshotInfo( + snapshot.snapshotId(), + snapshot.calculatedAt(), + snapshot.windowStartedAt(), + snapshot.windowEndedAt(), + snapshot.algorithmVersion(), + items); } private static void validateCoordinates(double latitude, double longitude) { @@ -92,9 +103,34 @@ private static void validateCoordinates(double latitude, double longitude) { } private static String normalizeLegacyRegionName(String keyword) { + return keyword .replace(FORMER_JEONBUK, CURRENT_JEONBUK) .replace(FORMER_JEONNAM, CURRENT_JEONNAM_GWANGJU) - .replace(FORMER_GWANGJU, CURRENT_JEONNAM_GWANGJU); + .replace(FORMER_GWANGJU, CURRENT_JEONNAM_GWANGJU) + .replace(FORMER_JEOLLA, CURRENT_JEOLLA_PREFIX) + .replace(FORMER_JEOLLA_SHORT, CURRENT_JEOLLA_PREFIX); + } + + private static PopularLocationSnapshotInfo.Item popularItem( + PopularLocationSnapshotData.Rank rank, + Map locationsById + ) { + LocationInfo location = locationsById.get(rank.locationId()); + + if (location == null) { + throw new IllegalStateException( + "popular snapshot references missing location: " + rank.locationId()); + } + + return new PopularLocationSnapshotInfo.Item( + rank.rank(), + rank.previousRank(), + rank.rankChange(), + rank.movement(), + rank.uniqueReporterCount(), + rank.reportCount(), + rank.latestReportAt(), + location); } } diff --git a/location/src/main/java/com/nalssilog/location/application/PopularLocationSource.java b/location/src/main/java/com/nalssilog/location/application/PopularLocationSource.java index 5ba0cd8..ff67745 100644 --- a/location/src/main/java/com/nalssilog/location/application/PopularLocationSource.java +++ b/location/src/main/java/com/nalssilog/location/application/PopularLocationSource.java @@ -1,13 +1,11 @@ package com.nalssilog.location.application; -import java.util.List; +import com.nalssilog.location.application.dto.PopularLocationSnapshotData; /** - * 인기 지역 랭킹의 공급자(port). 실제 데이터는 제보 활동에 기반하므로 report 모듈이 구현(adapter)한다. - * location 은 report 를 직접 의존하지 못하므로(의존 역방향), 이 인터페이스를 통해 역전시켜 받는다. - * 구현은 최근 활동이 많은 순서로 locationId 를 돌려주고, 데이터가 없으면 빈 목록을 반환한다. + * 인기 지역 랭킹의 공급자(port). 실제 집계와 스냅샷 저장은 report 모듈이 구현한다. */ public interface PopularLocationSource { - List topLocationIds(int size); + PopularLocationSnapshotData latestSnapshot(); } diff --git a/location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotData.java b/location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotData.java new file mode 100644 index 0000000..4325527 --- /dev/null +++ b/location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotData.java @@ -0,0 +1,34 @@ +package com.nalssilog.location.application.dto; + +import com.nalssilog.location.domain.PopularRankMovement; +import java.time.Instant; +import java.util.List; + +/** + * 제보 모듈이 계산·저장한 인기 지역 순위 스냅샷. + */ +public record PopularLocationSnapshotData( + Long snapshotId, + Instant calculatedAt, + Instant windowStartedAt, + Instant windowEndedAt, + String algorithmVersion, + List rankings +) { + + public PopularLocationSnapshotData { + rankings = List.copyOf(rankings); + } + + public record Rank( + Long locationId, + int rank, + Integer previousRank, + Integer rankChange, + PopularRankMovement movement, + long uniqueReporterCount, + long reportCount, + Instant latestReportAt + ) { + } +} diff --git a/location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotInfo.java b/location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotInfo.java new file mode 100644 index 0000000..0a5a296 --- /dev/null +++ b/location/src/main/java/com/nalssilog/location/application/dto/PopularLocationSnapshotInfo.java @@ -0,0 +1,34 @@ +package com.nalssilog.location.application.dto; + +import com.nalssilog.location.domain.PopularRankMovement; +import java.time.Instant; +import java.util.List; + +/** + * 인기 순위 스냅샷에 화면 표시용 지역 정보를 결합한 내부 응답. + */ +public record PopularLocationSnapshotInfo( + Long snapshotId, + Instant calculatedAt, + Instant windowStartedAt, + Instant windowEndedAt, + String algorithmVersion, + List items +) { + + public PopularLocationSnapshotInfo { + items = List.copyOf(items); + } + + public record Item( + int rank, + Integer previousRank, + Integer rankChange, + PopularRankMovement movement, + long uniqueReporterCount, + long reportCount, + Instant latestReportAt, + LocationInfo location + ) { + } +} diff --git a/location/src/main/java/com/nalssilog/location/config/LocationProperties.java b/location/src/main/java/com/nalssilog/location/config/LocationProperties.java deleted file mode 100644 index b1882e7..0000000 --- a/location/src/main/java/com/nalssilog/location/config/LocationProperties.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.nalssilog.location.config; - -import java.util.List; -import org.springframework.boot.context.properties.ConfigurationProperties; - -/** 지역 관련 설정. featuredAdminCodes 는 제보 0건일 때 인기 동네 자리에 노출할 대표 지역(순서 유지). */ -@ConfigurationProperties("nalssilog.location") -public record LocationProperties(List featuredAdminCodes) { - - public LocationProperties { - featuredAdminCodes = featuredAdminCodes == null ? List.of() : featuredAdminCodes; - } -} diff --git a/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java b/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java new file mode 100644 index 0000000..0983945 --- /dev/null +++ b/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java @@ -0,0 +1,32 @@ +package com.nalssilog.location.config; + +import java.time.Duration; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "nalssilog.location.popular") +public record PopularLocationProperties( + Duration window, + int limit, + Duration snapshotInterval +) { + + private static final Duration DEFAULT_WINDOW = Duration.ofDays(7); + private static final int DEFAULT_LIMIT = 20; + private static final Duration DEFAULT_SNAPSHOT_INTERVAL = Duration.ofMinutes(10); + private static final int MAX_LIMIT = 20; + + public PopularLocationProperties { + if (window == null || window.isZero() || window.isNegative()) { + window = DEFAULT_WINDOW; + } + if (limit < 1) { + limit = DEFAULT_LIMIT; + } + if (limit > MAX_LIMIT) { + throw new IllegalArgumentException("popular location limit must not exceed " + MAX_LIMIT); + } + if (snapshotInterval == null || snapshotInterval.isZero() || snapshotInterval.isNegative()) { + snapshotInterval = DEFAULT_SNAPSHOT_INTERVAL; + } + } +} diff --git a/location/src/main/java/com/nalssilog/location/domain/PopularRankMovement.java b/location/src/main/java/com/nalssilog/location/domain/PopularRankMovement.java new file mode 100644 index 0000000..812904f --- /dev/null +++ b/location/src/main/java/com/nalssilog/location/domain/PopularRankMovement.java @@ -0,0 +1,8 @@ +package com.nalssilog.location.domain; + +public enum PopularRankMovement { + NEW, + UP, + DOWN, + SAME +} diff --git a/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteJpaRepository.java b/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteJpaRepository.java deleted file mode 100644 index d5658ce..0000000 --- a/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteJpaRepository.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.nalssilog.location.repository; - -import com.nalssilog.location.domain.LocationFavorite; -import java.util.List; -import org.springframework.data.jpa.repository.JpaRepository; - -/** - * 단순 CRUD와 메서드 이름으로 표현 가능한 조회만 담당한다. - * 인기 지역 집계는 {@link LocationFavoriteRepository}가 QueryDSL로 처리한다. - */ -public interface LocationFavoriteJpaRepository extends JpaRepository { - - boolean existsByMemberIdAndLocationId(Long memberId, Long locationId); - - long deleteByMemberIdAndLocationId(Long memberId, Long locationId); - - List findAllByMemberIdOrderByCreatedAtDesc(Long memberId); -} diff --git a/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteRepository.java b/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteRepository.java index 9412267..e7dcb23 100644 --- a/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteRepository.java +++ b/location/src/main/java/com/nalssilog/location/repository/LocationFavoriteRepository.java @@ -1,49 +1,18 @@ package com.nalssilog.location.repository; -import static com.nalssilog.location.domain.QLocationFavorite.locationFavorite; - import com.nalssilog.location.domain.LocationFavorite; -import com.querydsl.jpa.impl.JPAQueryFactory; -import java.util.List; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; /** - * 서비스 호출용 LocationFavorite 저장소. - * 단순 조회는 Spring Data JPA에 위임하고, 인기 지역 집계는 QueryDSL로 처리한다. + * 단순 CRUD와 메서드 이름으로 표현 가능한 조회만 담당한다. */ -@Repository -@RequiredArgsConstructor -public class LocationFavoriteRepository { - - private final LocationFavoriteJpaRepository locationFavoriteJpaRepository; - private final JPAQueryFactory queryFactory; - - public boolean exists(Long memberId, Long locationId) { - return locationFavoriteJpaRepository.existsByMemberIdAndLocationId(memberId, locationId); - } - - public void save(LocationFavorite favorite) { - locationFavoriteJpaRepository.save(favorite); - } +public interface LocationFavoriteRepository extends JpaRepository { - public void delete(Long memberId, Long locationId) { - locationFavoriteJpaRepository.deleteByMemberIdAndLocationId(memberId, locationId); - } + boolean existsByMemberIdAndLocationId(Long memberId, Long locationId); - public List findFavoriteLocationIds(Long memberId) { - return locationFavoriteJpaRepository.findAllByMemberIdOrderByCreatedAtDesc(memberId).stream() - .map(LocationFavorite::getLocationId) - .toList(); - } + long deleteByMemberIdAndLocationId(Long memberId, Long locationId); - public List findPopularLocationIds(int size) { - return queryFactory - .select(locationFavorite.locationId) - .from(locationFavorite) - .groupBy(locationFavorite.locationId) - .orderBy(locationFavorite.id.count().desc()) - .limit(size) - .fetch(); - } + Page findAllByMemberIdOrderByCreatedAtDescIdDesc(Long memberId, Pageable pageable); } diff --git a/location/src/main/java/com/nalssilog/location/repository/LocationJpaRepository.java b/location/src/main/java/com/nalssilog/location/repository/LocationJpaRepository.java index 66ed931..7f7dce7 100644 --- a/location/src/main/java/com/nalssilog/location/repository/LocationJpaRepository.java +++ b/location/src/main/java/com/nalssilog/location/repository/LocationJpaRepository.java @@ -1,7 +1,6 @@ package com.nalssilog.location.repository; import com.nalssilog.location.domain.Location; -import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; @@ -11,7 +10,5 @@ */ public interface LocationJpaRepository extends JpaRepository { - List findByAdminCodeIn(List adminCodes); - Optional findByAdminCode(String adminCode); } diff --git a/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java b/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java index 9e94a09..4e62e09 100644 --- a/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java +++ b/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java @@ -7,19 +7,24 @@ import com.nalssilog.location.client.KakaoRegion; import com.nalssilog.location.domain.Location; import com.nalssilog.location.domain.LocationErrorCode; +import com.querydsl.core.BooleanBuilder; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.CaseBuilder; +import com.querydsl.core.types.dsl.NumberExpression; +import com.querydsl.core.types.dsl.StringExpression; import com.querydsl.jpa.impl.JPAQueryFactory; -import jakarta.persistence.EntityManager; -import java.time.Instant; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.hibernate.Session; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; -import org.springframework.transaction.annotation.Transactional; /** * 서비스 호출용 Location 저장소. @@ -29,38 +34,86 @@ @RequiredArgsConstructor public class LocationRepository { - private static final int SEARCH_LIMIT = 20; - private static final String INSERT_IF_ABSENT = """ - insert Location (createdAt, updatedAt, adminCode, sido, sigungu, dong, latitude, longitude) - values (:now, :now, :adminCode, :sido, :sigungu, :dong, :latitude, :longitude) - on conflict (adminCode) do nothing - """; - private final LocationJpaRepository locationJpaRepository; private final JPAQueryFactory queryFactory; - private final EntityManager entityManager; - - public List searchByKeyword(String keyword) { - BooleanExpression matchesKeyword = location.sido.containsIgnoreCase(keyword) - .or(location.sigungu.containsIgnoreCase(keyword)) - .or(location.dong.containsIgnoreCase(keyword)) - .or(location.sido.concat(" ") - .concat(location.sigungu).concat(" ") - .concat(location.dong) - .containsIgnoreCase(keyword)); - - return queryFactory + + public Page searchByKeyword(String keyword, Pageable pageable) { + List tokens = Arrays.stream(keyword.split("\\s+")) + .filter(token -> !token.isBlank()) + .toList(); + + if (tokens.isEmpty()) { + + return Page.empty(pageable); + } + + StringExpression label = location.sido.concat(" ") + .concat(location.sigungu).concat(" ") + .concat(location.dong); + BooleanBuilder matchesKeyword = new BooleanBuilder(); + + tokens.forEach(token -> matchesKeyword.and(matchesToken(label, token))); + + String firstToken = tokens.getFirst(); + NumberExpression relevance = new CaseBuilder() + .when(location.sido.eq(keyword) + .or(location.sigungu.eq(keyword)) + .or(location.dong.eq(keyword)) + .or(label.eq(keyword))) + .then(0) + .when(location.sido.startsWith(firstToken)) + .then(1) + .when(location.sigungu.startsWith(firstToken)) + .then(2) + .when(location.dong.startsWith(firstToken)) + .then(3) + .when(label.startsWith(keyword)) + .then(4) + .otherwise(5); + + List items = queryFactory .selectFrom(location) .where(matchesKeyword) - .orderBy(location.sido.asc(), location.sigungu.asc(), location.dong.asc()) - .limit(SEARCH_LIMIT) + .orderBy( + relevance.asc(), + location.sido.asc(), + location.sigungu.asc(), + location.dong.asc(), + location.id.asc()) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) .fetch() .stream() .map(LocationInfo::of) .toList(); + Long totalElements = queryFactory + .select(location.count()) + .from(location) + .where(matchesKeyword) + .fetchOne(); + + return new PageImpl<>( + items, + pageable, + totalElements == null ? 0 : totalElements); + } + + private BooleanExpression matchesToken(StringExpression label, String token) { + BooleanExpression prefixMatch = location.sido.startsWith(token) + .or(location.sigungu.startsWith(token)) + .or(location.dong.startsWith(token)) + .or(label.startsWith(token)); + + if (token.length() == 1) { + + return prefixMatch; + } + + return prefixMatch.or(label.contains(token)); } public LocationInfo getById(Long id) { + return locationJpaRepository.findById(id) .map(LocationInfo::of) .orElseThrow(() -> new NalssiLogException(LocationErrorCode.LOCATION_NOT_FOUND)); @@ -78,19 +131,8 @@ public List findByIds(List ids) { .toList(); } - /** 대표 지역을 admin_code 순서대로 조회한다(설정에 있으나 DB 에 없는 코드는 조용히 제외). */ - public List findByAdminCodes(List adminCodes) { - Map byCode = locationJpaRepository.findByAdminCodeIn(adminCodes).stream() - .collect(Collectors.toMap(Location::getAdminCode, Function.identity())); - - return adminCodes.stream() - .map(byCode::get) - .filter(Objects::nonNull) - .map(LocationInfo::of) - .toList(); - } - public boolean isEmpty() { + return locationJpaRepository.count() == 0; } @@ -99,24 +141,36 @@ public void saveAll(List locations) { } /** - * 카카오 법정동 코드로 지역을 원자적으로 등록한 뒤 반환한다. - * QueryDSL JPA가 INSERT를 지원하지 않아 Hibernate HQL upsert를 사용한다. + * 카카오 법정동 코드를 먼저 조회하고 없으면 JPA로 등록한다. + * 동시에 같은 코드가 등록되면 유니크 키가 승자를 정하고 커밋된 행을 다시 조회한다. */ - @Transactional public LocationInfo findOrCreate(KakaoRegion region) { - Instant now = Instant.now(); - entityManager.unwrap(Session.class) - .createMutationQuery(INSERT_IF_ABSENT) - .setParameter("now", now) - .setParameter("adminCode", region.adminCode()) - .setParameter("sido", region.sido()) - .setParameter("sigungu", region.sigungu()) - .setParameter("dong", region.dong()) - .setParameter("latitude", region.latitude()) - .setParameter("longitude", region.longitude()) - .executeUpdate(); return locationJpaRepository.findByAdminCode(region.adminCode()) + .map(LocationInfo::of) + .orElseGet(() -> saveOrLoadConcurrent(region)); + } + + private LocationInfo saveOrLoadConcurrent(KakaoRegion region) { + try { + Location location = Location.of( + region.adminCode(), + region.sido(), + region.sigungu(), + region.dong(), + region.latitude(), + region.longitude()); + + return LocationInfo.of(locationJpaRepository.saveAndFlush(location)); + } catch (DataIntegrityViolationException exception) { + + return findByAdminCodeOrThrow(region.adminCode()); + } + } + + private LocationInfo findByAdminCodeOrThrow(String adminCode) { + + return locationJpaRepository.findByAdminCode(adminCode) .map(LocationInfo::of) .orElseThrow(() -> new NalssiLogException(LocationErrorCode.LOCATION_NOT_FOUND)); } diff --git a/location/src/test/java/com/nalssilog/location/api/LocationControllerTest.java b/location/src/test/java/com/nalssilog/location/api/LocationControllerTest.java new file mode 100644 index 0000000..7b4e855 --- /dev/null +++ b/location/src/test/java/com/nalssilog/location/api/LocationControllerTest.java @@ -0,0 +1,106 @@ +package com.nalssilog.location.api; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.nalssilog.common.response.PageResponse; +import com.nalssilog.location.application.LocationService; +import com.nalssilog.location.application.dto.LocationInfo; +import com.nalssilog.location.application.dto.PopularLocationSnapshotInfo; +import com.nalssilog.location.domain.PopularRankMovement; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +@SuppressWarnings("java:S5960") +class LocationControllerTest { + + private final LocationService locationService = mock(LocationService.class); + private final LocationController controller = new LocationController(locationService); + private final MockMvc mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); + + @Test + void returnsAtomicPopularSnapshotWithFiveItemDisplayMetadata() throws Exception { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + Instant windowStartedAt = calculatedAt.minusSeconds(7 * 24 * 60 * 60); + LocationInfo location = new LocationInfo( + 1L, "서울특별시", "강남구", "역삼동", 37.5, 127.0); + PopularLocationSnapshotInfo snapshot = new PopularLocationSnapshotInfo( + 31L, + calculatedAt, + windowStartedAt, + calculatedAt, + "UNIQUE_REPORTERS_V1", + List.of(new PopularLocationSnapshotInfo.Item( + 1, + 3, + 2, + PopularRankMovement.UP, + 4, + 7, + calculatedAt.minusSeconds(60), + location))); + + when(locationService.getPopular()).thenReturn(snapshot); + + mockMvc.perform(get("/api/locations/popular")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.snapshotId").value("31")) + .andExpect(jsonPath("$.algorithmVersion").value("UNIQUE_REPORTERS_V1")) + .andExpect(jsonPath("$.pageSize").value(5)) + .andExpect(jsonPath("$.totalElements").value(1)) + .andExpect(jsonPath("$.totalPages").value(1)) + .andExpect(jsonPath("$.items[0].rank").value(1)) + .andExpect(jsonPath("$.items[0].previousRank").value(3)) + .andExpect(jsonPath("$.items[0].rankChange").value(2)) + .andExpect(jsonPath("$.items[0].movement").value("UP")) + .andExpect(jsonPath("$.items[0].uniqueReporterCount").value(4)) + .andExpect(jsonPath("$.items[0].reportCount").value(7)) + .andExpect(jsonPath("$.items[0].location.id").value("1")); + verify(locationService).getPopular(); + } + + @Test + void usesZeroAsDefaultSearchPageAndReturnsTotalMetadata() throws Exception { + when(locationService.search("서", 0)) + .thenReturn(PageResponse.of(List.of(), 0, 5, 0)); + + mockMvc.perform(get("/api/locations").param("keyword", "서")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.items").isArray()) + .andExpect(jsonPath("$.page").value(0)) + .andExpect(jsonPath("$.size").value(5)) + .andExpect(jsonPath("$.totalElements").value(0)) + .andExpect(jsonPath("$.totalPages").value(0)) + .andExpect(jsonPath("$.hasPrevious").value(false)) + .andExpect(jsonPath("$.hasNext").value(false)); + + verify(locationService).search("서", 0); + } + + @Test + void rejectsNegativeSearchPage() throws Exception { + mockMvc.perform(get("/api/locations") + .param("keyword", "서") + .param("page", "-1")) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(locationService); + } + + @Test + void rejectsOversizedSearchKeyword() throws Exception { + mockMvc.perform(get("/api/locations") + .param("keyword", "가".repeat(51))) + .andExpect(status().isBadRequest()); + + verifyNoInteractions(locationService); + } +} diff --git a/location/src/test/java/com/nalssilog/location/api/LocationFavoriteControllerTest.java b/location/src/test/java/com/nalssilog/location/api/LocationFavoriteControllerTest.java new file mode 100644 index 0000000..cb13d65 --- /dev/null +++ b/location/src/test/java/com/nalssilog/location/api/LocationFavoriteControllerTest.java @@ -0,0 +1,47 @@ +package com.nalssilog.location.api; + +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.common.response.PageResponse; +import com.nalssilog.location.application.LocationFavoriteService; +import com.nalssilog.location.application.dto.LocationInfo; +import java.util.List; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class LocationFavoriteControllerTest { + + private final LocationFavoriteService favoriteService = + mock(LocationFavoriteService.class); + private final LocationFavoriteController controller = + new LocationFavoriteController(favoriteService); + + @Test + void preservesFavoritePageTotalsWhileMappingLocations() { + LocationInfo location = new LocationInfo( + 1L, + "서울특별시", + "강남구", + "역삼동", + 37.5, + 127.0); + + when(favoriteService.listFavorites(7L, 1)) + .thenReturn(PageResponse.of(List.of(location), 1, 5, 8)); + + var result = controller.myFavorites(7L, 1); + + assertThat(result.page()).isEqualTo(1); + assertThat(result.size()).isEqualTo(5); + assertThat(result.totalElements()).isEqualTo(8); + assertThat(result.totalPages()).isEqualTo(2); + assertThat(result.hasPrevious()).isTrue(); + assertThat(result.hasNext()).isFalse(); + assertThat(result.items()).singleElement() + .satisfies(item -> assertThat(item.id()).isEqualTo("1")); + verify(favoriteService).listFavorites(7L, 1); + } +} diff --git a/location/src/test/java/com/nalssilog/location/api/dto/PopularLocationsResponseTest.java b/location/src/test/java/com/nalssilog/location/api/dto/PopularLocationsResponseTest.java new file mode 100644 index 0000000..14bd9e3 --- /dev/null +++ b/location/src/test/java/com/nalssilog/location/api/dto/PopularLocationsResponseTest.java @@ -0,0 +1,51 @@ +package com.nalssilog.location.api.dto; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.nalssilog.location.application.dto.LocationInfo; +import com.nalssilog.location.application.dto.PopularLocationSnapshotInfo; +import com.nalssilog.location.domain.PopularRankMovement; +import java.time.Instant; +import java.util.List; +import java.util.stream.IntStream; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class PopularLocationsResponseTest { + + @Test + void calculatesFrontendPagesWhileReturningOneAtomicSnapshot() { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + List items = IntStream.rangeClosed(1, 6) + .mapToObj(rank -> new PopularLocationSnapshotInfo.Item( + rank, + rank, + 0, + PopularRankMovement.SAME, + 1, + 1, + calculatedAt.minusSeconds(rank), + new LocationInfo( + (long) rank, + "서울특별시", + "강남구", + "동" + rank, + null, + null))) + .toList(); + PopularLocationSnapshotInfo snapshot = new PopularLocationSnapshotInfo( + 10L, + calculatedAt, + calculatedAt.minusSeconds(7 * 24 * 60 * 60), + calculatedAt, + "UNIQUE_REPORTERS_V1", + items); + + PopularLocationsResponse response = PopularLocationsResponse.from(snapshot); + + assertThat(response.pageSize()).isEqualTo(5); + assertThat(response.totalElements()).isEqualTo(6); + assertThat(response.totalPages()).isEqualTo(2); + assertThat(response.items()).hasSize(6); + } +} diff --git a/location/src/test/java/com/nalssilog/location/application/LocationFavoriteServiceTest.java b/location/src/test/java/com/nalssilog/location/application/LocationFavoriteServiceTest.java new file mode 100644 index 0000000..878016d --- /dev/null +++ b/location/src/test/java/com/nalssilog/location/application/LocationFavoriteServiceTest.java @@ -0,0 +1,60 @@ +package com.nalssilog.location.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.common.response.PageResponse; +import com.nalssilog.location.application.dto.LocationInfo; +import com.nalssilog.location.domain.LocationFavorite; +import com.nalssilog.location.repository.LocationFavoriteRepository; +import com.nalssilog.location.repository.LocationRepository; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@SuppressWarnings("java:S5960") +class LocationFavoriteServiceTest { + + private final LocationFavoriteRepository favoriteRepository = + mock(LocationFavoriteRepository.class); + private final LocationRepository locationRepository = + mock(LocationRepository.class); + private final LocationFavoriteService service = + new LocationFavoriteService(favoriteRepository, locationRepository); + + @Test + void loadsFavoritesInFixedFiveItemPagesWithTotals() { + List ids = List.of(11L, 12L); + List locations = ids.stream() + .map(id -> new LocationInfo( + id, + "서울특별시", + "강남구", + "동" + id, + null, + null)) + .toList(); + List favorites = ids.stream() + .map(id -> LocationFavorite.of(7L, id)) + .toList(); + PageRequest pageable = PageRequest.of(2, 5); + + when(favoriteRepository.findAllByMemberIdOrderByCreatedAtDescIdDesc(7L, pageable)) + .thenReturn(new PageImpl<>(favorites, pageable, 12)); + when(locationRepository.findByIds(ids)).thenReturn(locations); + + PageResponse result = service.listFavorites(7L, 2); + + assertThat(result.items()).containsExactlyElementsOf(locations); + assertThat(result.page()).isEqualTo(2); + assertThat(result.size()).isEqualTo(5); + assertThat(result.totalElements()).isEqualTo(12); + assertThat(result.totalPages()).isEqualTo(3); + assertThat(result.hasPrevious()).isTrue(); + assertThat(result.hasNext()).isFalse(); + verify(favoriteRepository).findAllByMemberIdOrderByCreatedAtDescIdDesc(7L, pageable); + } +} diff --git a/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java b/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java index 47ac6a7..b7dd785 100644 --- a/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java +++ b/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java @@ -5,18 +5,22 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; -import static org.mockito.Mockito.never; import static org.mockito.Mockito.when; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.location.application.dto.LocationInfo; +import com.nalssilog.location.application.dto.PopularLocationSnapshotData; import com.nalssilog.location.client.KakaoMapClient; import com.nalssilog.location.client.KakaoRegion; -import com.nalssilog.location.config.LocationProperties; import com.nalssilog.location.domain.LocationErrorCode; +import com.nalssilog.location.domain.PopularRankMovement; import com.nalssilog.location.repository.LocationRepository; +import java.time.Instant; import java.util.List; import org.junit.jupiter.api.Test; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; @SuppressWarnings("java:S5960") // 표준 src/test 소스의 AssertJ 검증을 운영 코드 assertion으로 오인하는 경고. class LocationServiceTest { @@ -27,7 +31,6 @@ class LocationServiceTest { private final LocationService service = new LocationService( locationRepository, popularLocationSource, - new LocationProperties(List.of()), kakaoMapClient ); @@ -37,6 +40,7 @@ void resolvesKakaoRegionAndReturnsPersistedLocation() { "1168010100", "서울특별시", "강남구", "역삼동", 37.500622, 127.036456); LocationInfo expected = new LocationInfo( 1L, "서울특별시", "강남구", "역삼동", 37.500622, 127.036456); + when(kakaoMapClient.reverseGeocode(37.5, 127.03)).thenReturn(region); when(locationRepository.findOrCreate(region)).thenReturn(expected); @@ -57,63 +61,136 @@ void rejectsInvalidCoordinatesBeforeCallingKakao() { @Test void searchesJeonbukWithItsCurrentOfficialNameWhenLegacyNameIsEntered() { - service.search("전라북도 전주시"); + PageRequest pageable = PageRequest.of(0, 5); + + when(locationRepository.searchByKeyword("전북특별자치도 전주시", pageable)) + .thenReturn(Page.empty(pageable)); + + service.search("전라북도 전주시", 0); - verify(locationRepository).searchByKeyword("전북특별자치도 전주시"); + verify(locationRepository).searchByKeyword("전북특별자치도 전주시", pageable); } @Test void searchesIntegratedJeonnamGwangjuWhenLegacyNameIsEntered() { - service.search("전라남도 순천시"); + PageRequest pageable = PageRequest.of(2, 5); + + when(locationRepository.searchByKeyword("전남광주통합특별시 순천시", pageable)) + .thenReturn(Page.empty(pageable)); + + service.search("전라남도 순천시", 2); + + verify(locationRepository).searchByKeyword("전남광주통합특별시 순천시", pageable); + } + + @Test + void expandsAmbiguousLegacyJeollaNameToBothCurrentRegions() { + PageRequest pageable = PageRequest.of(0, 5); + + when(locationRepository.searchByKeyword("전", pageable)) + .thenReturn(Page.empty(pageable)); + + service.search("전라", 0); + + verify(locationRepository).searchByKeyword("전", pageable); + } + + @Test + void keepsFollowingTokensWhenLegacyJeollaNameIsEntered() { + PageRequest pageable = PageRequest.of(0, 5); + + when(locationRepository.searchByKeyword("전 전주시", pageable)) + .thenReturn(Page.empty(pageable)); + + service.search("전라도 전주시", 0); + + verify(locationRepository).searchByKeyword("전 전주시", pageable); + } + + @Test + void normalizesRepeatedWhitespaceBeforeSearching() { + PageRequest pageable = PageRequest.of(0, 5); - verify(locationRepository).searchByKeyword("전남광주통합특별시 순천시"); + when(locationRepository.searchByKeyword("서울 강남구", pageable)) + .thenReturn(Page.empty(pageable)); + + service.search(" 서울 강남구 ", 0); + + verify(locationRepository).searchByKeyword("서울 강남구", pageable); } @Test - void fillsPopularLocationsToFiveWithNonDuplicateFeaturedLocations() { - List featuredCodes = List.of("code-2", "code-3", "code-4", "code-5", "code-6"); - LocationService popularService = new LocationService( - locationRepository, - popularLocationSource, - new LocationProperties(featuredCodes), - kakaoMapClient - ); - LocationInfo popularOne = location(1L, "인기동1"); - LocationInfo popularTwo = location(2L, "인기동2"); - LocationInfo featuredThree = location(3L, "대표동3"); - LocationInfo featuredFour = location(4L, "대표동4"); - LocationInfo featuredFive = location(5L, "대표동5"); - LocationInfo featuredSix = location(6L, "대표동6"); - - when(popularLocationSource.topLocationIds(5)).thenReturn(List.of(1L, 2L)); - when(locationRepository.findByIds(List.of(1L, 2L))).thenReturn(List.of(popularOne, popularTwo)); - when(locationRepository.findByAdminCodes(featuredCodes)).thenReturn(List.of( - popularTwo, featuredThree, featuredFour, featuredFive, featuredSix)); - - List result = popularService.getPopular(); - - assertThat(result).containsExactly( - popularOne, popularTwo, featuredThree, featuredFour, featuredFive); + void returnsFiveSearchItemsWithTotalPageMetadata() { + List items = List.of( + location(1L, "동1"), + location(2L, "동2"), + location(3L, "동3"), + location(4L, "동4"), + location(5L, "동5")); + PageRequest pageable = PageRequest.of(1, 5); + + when(locationRepository.searchByKeyword("서", pageable)) + .thenReturn(new PageImpl<>(items, pageable, 12)); + + var result = service.search("서", 1); + + assertThat(result.items()).containsExactlyElementsOf(items); + assertThat(result.page()).isEqualTo(1); + assertThat(result.size()).isEqualTo(5); + assertThat(result.totalElements()).isEqualTo(12); + assertThat(result.totalPages()).isEqualTo(3); + assertThat(result.hasPrevious()).isTrue(); + assertThat(result.hasNext()).isTrue(); } @Test - void doesNotLoadFeaturedLocationsWhenFivePopularLocationsExist() { - List popularIds = List.of(1L, 2L, 3L, 4L, 5L); - List popular = List.of( - location(1L, "인기동1"), - location(2L, "인기동2"), - location(3L, "인기동3"), - location(4L, "인기동4"), - location(5L, "인기동5") - ); - when(popularLocationSource.topLocationIds(5)).thenReturn(popularIds); - when(locationRepository.findByIds(popularIds)).thenReturn(popular); - - assertThat(service.getPopular()).containsExactlyElementsOf(popular); - verify(locationRepository, never()).findByAdminCodes(List.of()); + void enrichesPopularSnapshotWithoutChangingItsRanking() { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + Instant windowStartedAt = calculatedAt.minusSeconds(7 * 24 * 60 * 60); + LocationInfo first = location(2L, "인기동1"); + LocationInfo second = location(1L, "인기동2"); + PopularLocationSnapshotData snapshot = new PopularLocationSnapshotData( + 31L, + calculatedAt, + windowStartedAt, + calculatedAt, + "UNIQUE_REPORTERS_V1", + List.of( + new PopularLocationSnapshotData.Rank( + 2L, + 1, + 3, + 2, + PopularRankMovement.UP, + 4, + 7, + calculatedAt.minusSeconds(60)), + new PopularLocationSnapshotData.Rank( + 1L, + 2, + 1, + -1, + PopularRankMovement.DOWN, + 3, + 5, + calculatedAt.minusSeconds(120)))); + + when(popularLocationSource.latestSnapshot()).thenReturn(snapshot); + when(locationRepository.findByIds(List.of(2L, 1L))).thenReturn(List.of(first, second)); + + var result = service.getPopular(); + + assertThat(result.snapshotId()).isEqualTo(31L); + assertThat(result.items()).extracting(item -> item.location().id()) + .containsExactly(2L, 1L); + assertThat(result.items()).extracting(item -> item.rank()) + .containsExactly(1, 2); + assertThat(result.items().getFirst().movement()).isEqualTo(PopularRankMovement.UP); + assertThat(result.items().getFirst().uniqueReporterCount()).isEqualTo(4); } private LocationInfo location(Long id, String dong) { + return new LocationInfo(id, "서울특별시", "강남구", dong, 37.5, 127.0); } } diff --git a/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotScheduler.java b/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotScheduler.java new file mode 100644 index 0000000..6435649 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotScheduler.java @@ -0,0 +1,20 @@ +package com.nalssilog.report.application; + +import java.time.Instant; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class PopularLocationSnapshotScheduler { + + private final PopularLocationSnapshotService snapshotService; + + @Scheduled( + fixedDelayString = "${nalssilog.location.popular.snapshot-interval:10m}", + initialDelayString = "${nalssilog.location.popular.snapshot-initial-delay:10s}") + public void refreshSnapshot() { + snapshotService.latestOrRefreshAt(Instant.now()); + } +} diff --git a/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java b/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java new file mode 100644 index 0000000..1a7915f --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java @@ -0,0 +1,184 @@ +package com.nalssilog.report.application; + +import com.nalssilog.location.application.dto.PopularLocationSnapshotData; +import com.nalssilog.location.config.PopularLocationProperties; +import com.nalssilog.location.domain.PopularRankMovement; +import com.nalssilog.report.application.dto.PopularLocationAggregate; +import com.nalssilog.report.domain.PopularLocationRank; +import com.nalssilog.report.domain.PopularLocationSnapshot; +import com.nalssilog.report.repository.PopularLocationRankJpaRepository; +import com.nalssilog.report.repository.PopularLocationSnapshotJpaRepository; +import com.nalssilog.report.repository.WeatherReportRepository; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +/** + * 최근 제보 집계를 순위 스냅샷으로 저장하고 최신 스냅샷을 제공한다. + */ +@Service +@RequiredArgsConstructor +public class PopularLocationSnapshotService { + + private static final String ALGORITHM_VERSION = "UNIQUE_REPORTERS_V1"; + + private final WeatherReportRepository reportRepository; + private final PopularLocationSnapshotJpaRepository snapshotRepository; + private final PopularLocationRankJpaRepository rankRepository; + private final PopularLocationProperties properties; + + @Transactional + public PopularLocationSnapshotData latestOrRefreshAt(Instant now) { + PopularLocationSnapshot latest = snapshotRepository + .findFirstByOrderByCalculatedAtDescIdDesc() + .orElse(null); + + if (latest != null + && !latest.getCalculatedAt().isBefore(now.minus(properties.snapshotInterval()))) { + + return snapshotData(latest); + } + + return createSnapshot(now, latest); + } + + @Transactional + public PopularLocationSnapshotData captureAt(Instant calculatedAt) { + PopularLocationSnapshot previous = snapshotRepository + .findFirstByOrderByCalculatedAtDescIdDesc() + .orElse(null); + + return createSnapshot(calculatedAt, previous); + } + + private PopularLocationSnapshotData createSnapshot( + Instant calculatedAt, + PopularLocationSnapshot previous + ) { + Instant snapshotTime = calculatedAt.truncatedTo(ChronoUnit.SECONDS); + Instant windowStartedAt = snapshotTime.minus(properties.window()); + List aggregates = + reportRepository.findPopularLocationAggregates( + windowStartedAt, + snapshotTime, + properties.limit()); + Map previousPositions = previousPositions(previous); + PopularLocationSnapshot snapshot = PopularLocationSnapshot.create( + snapshotTime, + windowStartedAt, + snapshotTime, + properties.limit(), + ALGORITHM_VERSION); + + snapshotRepository.save(snapshot); + + List ranks = new ArrayList<>(aggregates.size()); + + for (int index = 0; index < aggregates.size(); index++) { + PopularLocationAggregate aggregate = aggregates.get(index); + int position = index + 1; + Integer previousPosition = previousPositions.get(aggregate.locationId()); + Integer rankChange = previousPosition == null + ? null + : previousPosition - position; + + ranks.add(PopularLocationRank.create( + snapshot.getId(), + aggregate.locationId(), + position, + previousPosition, + rankChange, + movement(rankChange), + aggregate.uniqueReporterCount(), + aggregate.reportCount(), + aggregate.latestReportAt())); + } + + rankRepository.saveAll(ranks); + + return snapshotData(snapshot, ranks); + } + + private Map previousPositions(PopularLocationSnapshot previous) { + if (!isComparable(previous)) { + + return Map.of(); + } + + return rankRepository.findAllBySnapshotIdOrderByPositionAsc(previous.getId()).stream() + .collect(Collectors.toMap( + PopularLocationRank::getLocationId, + PopularLocationRank::getPosition)); + } + + private boolean isComparable(PopularLocationSnapshot previous) { + if (previous == null + || !ALGORITHM_VERSION.equals(previous.getAlgorithmVersion()) + || previous.getRankingLimit() != properties.limit()) { + + return false; + } + + Duration previousWindow = Duration.between( + previous.getWindowStartedAt(), + previous.getWindowEndedAt()); + + return previousWindow.equals(properties.window()); + } + + private PopularLocationSnapshotData snapshotData(PopularLocationSnapshot snapshot) { + List ranks = + rankRepository.findAllBySnapshotIdOrderByPositionAsc(snapshot.getId()); + + return snapshotData(snapshot, ranks); + } + + private PopularLocationSnapshotData snapshotData( + PopularLocationSnapshot snapshot, + List ranks + ) { + List rankings = ranks.stream() + .map(rank -> new PopularLocationSnapshotData.Rank( + rank.getLocationId(), + rank.getPosition(), + rank.getPreviousPosition(), + rank.getRankChange(), + rank.getMovement(), + rank.getUniqueReporterCount(), + rank.getReportCount(), + rank.getLatestReportAt())) + .toList(); + + return new PopularLocationSnapshotData( + snapshot.getId(), + snapshot.getCalculatedAt(), + snapshot.getWindowStartedAt(), + snapshot.getWindowEndedAt(), + snapshot.getAlgorithmVersion(), + rankings); + } + + private static PopularRankMovement movement(Integer rankChange) { + if (rankChange == null) { + + return PopularRankMovement.NEW; + } + if (rankChange > 0) { + + return PopularRankMovement.UP; + } + if (rankChange < 0) { + + return PopularRankMovement.DOWN; + } + + return PopularRankMovement.SAME; + } +} diff --git a/report/src/main/java/com/nalssilog/report/application/ReportPopularLocationSource.java b/report/src/main/java/com/nalssilog/report/application/ReportPopularLocationSource.java index 909b9b2..9321afd 100644 --- a/report/src/main/java/com/nalssilog/report/application/ReportPopularLocationSource.java +++ b/report/src/main/java/com/nalssilog/report/application/ReportPopularLocationSource.java @@ -1,27 +1,23 @@ package com.nalssilog.report.application; import com.nalssilog.location.application.PopularLocationSource; -import com.nalssilog.report.repository.WeatherReportRepository; -import java.time.Duration; +import com.nalssilog.location.application.dto.PopularLocationSnapshotData; import java.time.Instant; -import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; /** - * location 의 인기 지역 port 를 제보 활동 기반으로 구현(adapter). 최근 {@link #POPULAR_WINDOW} 내 - * 제보가 많은 순으로 locationId 를 돌려준다. (report → location 의존이라 location 인터페이스 구현 가능) + * location의 인기 지역 port를 최근 제보 순위 스냅샷 기반으로 구현한다. */ @Component @RequiredArgsConstructor public class ReportPopularLocationSource implements PopularLocationSource { - private static final Duration POPULAR_WINDOW = Duration.ofDays(7); - - private final WeatherReportRepository reportRepository; + private final PopularLocationSnapshotService snapshotService; @Override - public List topLocationIds(int size) { - return reportRepository.topLocationIds(Instant.now().minus(POPULAR_WINDOW), size); + public PopularLocationSnapshotData latestSnapshot() { + + return snapshotService.latestOrRefreshAt(Instant.now()); } } diff --git a/report/src/main/java/com/nalssilog/report/application/dto/PopularLocationAggregate.java b/report/src/main/java/com/nalssilog/report/application/dto/PopularLocationAggregate.java new file mode 100644 index 0000000..1ba7f09 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/application/dto/PopularLocationAggregate.java @@ -0,0 +1,14 @@ +package com.nalssilog.report.application.dto; + +import java.time.Instant; + +/** + * 한 계산 구간에서 지역별로 집계한 인기 순위 원본 지표. + */ +public record PopularLocationAggregate( + Long locationId, + long uniqueReporterCount, + long reportCount, + Instant latestReportAt +) { +} diff --git a/report/src/main/java/com/nalssilog/report/domain/PopularLocationRank.java b/report/src/main/java/com/nalssilog/report/domain/PopularLocationRank.java new file mode 100644 index 0000000..f1daa81 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/PopularLocationRank.java @@ -0,0 +1,96 @@ +package com.nalssilog.report.domain; + +import com.nalssilog.location.domain.PopularRankMovement; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 인기 지역 스냅샷의 개별 순위와 당시 집계 지표. + */ +@Entity +@Table(name = "popular_location_rank", + indexes = @Index( + name = "idx_popular_location_rank_snapshot", + columnList = "snapshot_id, ranking_position"), + uniqueConstraints = { + @UniqueConstraint( + name = "uk_popular_location_rank_snapshot_position", + columnNames = {"snapshot_id", "ranking_position"}), + @UniqueConstraint( + name = "uk_popular_location_rank_snapshot_location", + columnNames = {"snapshot_id", "location_id"}) + }) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PopularLocationRank { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "snapshot_id", nullable = false) + private Long snapshotId; + + @Column(name = "location_id", nullable = false) + private Long locationId; + + @Column(name = "ranking_position", nullable = false) + private int position; + + @Column(name = "previous_ranking_position") + private Integer previousPosition; + + @Column(name = "rank_change") + private Integer rankChange; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private PopularRankMovement movement; + + @Column(name = "unique_reporter_count", nullable = false) + private long uniqueReporterCount; + + @Column(name = "report_count", nullable = false) + private long reportCount; + + @Column(name = "latest_report_at", nullable = false) + private Instant latestReportAt; + + public static PopularLocationRank create( + Long snapshotId, + Long locationId, + int position, + Integer previousPosition, + Integer rankChange, + PopularRankMovement movement, + long uniqueReporterCount, + long reportCount, + Instant latestReportAt + ) { + PopularLocationRank rank = new PopularLocationRank(); + + rank.snapshotId = snapshotId; + rank.locationId = locationId; + rank.position = position; + rank.previousPosition = previousPosition; + rank.rankChange = rankChange; + rank.movement = movement; + rank.uniqueReporterCount = uniqueReporterCount; + rank.reportCount = reportCount; + rank.latestReportAt = latestReportAt; + + return rank; + } +} diff --git a/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshot.java b/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshot.java new file mode 100644 index 0000000..ce0e82f --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshot.java @@ -0,0 +1,63 @@ +package com.nalssilog.report.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Index; +import jakarta.persistence.Table; +import java.time.Instant; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 특정 계산 시점의 인기 지역 순위 헤더. + */ +@Entity +@Table(name = "popular_location_snapshot", + indexes = @Index( + name = "idx_popular_location_snapshot_calculated", + columnList = "calculated_at")) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PopularLocationSnapshot { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "calculated_at", nullable = false) + private Instant calculatedAt; + + @Column(name = "window_started_at", nullable = false) + private Instant windowStartedAt; + + @Column(name = "window_ended_at", nullable = false) + private Instant windowEndedAt; + + @Column(name = "ranking_limit", nullable = false) + private int rankingLimit; + + @Column(name = "algorithm_version", nullable = false, length = 40) + private String algorithmVersion; + + public static PopularLocationSnapshot create( + Instant calculatedAt, + Instant windowStartedAt, + Instant windowEndedAt, + int rankingLimit, + String algorithmVersion + ) { + PopularLocationSnapshot snapshot = new PopularLocationSnapshot(); + + snapshot.calculatedAt = calculatedAt; + snapshot.windowStartedAt = windowStartedAt; + snapshot.windowEndedAt = windowEndedAt; + snapshot.rankingLimit = rankingLimit; + snapshot.algorithmVersion = algorithmVersion; + + return snapshot; + } +} diff --git a/report/src/main/java/com/nalssilog/report/repository/PopularLocationRankJpaRepository.java b/report/src/main/java/com/nalssilog/report/repository/PopularLocationRankJpaRepository.java new file mode 100644 index 0000000..d761fce --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/repository/PopularLocationRankJpaRepository.java @@ -0,0 +1,11 @@ +package com.nalssilog.report.repository; + +import com.nalssilog.report.domain.PopularLocationRank; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PopularLocationRankJpaRepository + extends JpaRepository { + + List findAllBySnapshotIdOrderByPositionAsc(Long snapshotId); +} diff --git a/report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotJpaRepository.java b/report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotJpaRepository.java new file mode 100644 index 0000000..4d4f9dc --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotJpaRepository.java @@ -0,0 +1,11 @@ +package com.nalssilog.report.repository; + +import com.nalssilog.report.domain.PopularLocationSnapshot; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PopularLocationSnapshotJpaRepository + extends JpaRepository { + + Optional findFirstByOrderByCalculatedAtDescIdDesc(); +} diff --git a/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java b/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java index a6b16a1..f4b26f6 100644 --- a/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java +++ b/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java @@ -3,6 +3,7 @@ import static com.nalssilog.report.domain.QWeatherReport.weatherReport; import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.application.dto.PopularLocationAggregate; import com.nalssilog.report.application.dto.ReportData; import com.nalssilog.report.application.dto.WeatherStatsData; import com.nalssilog.report.domain.ActorType; @@ -13,6 +14,7 @@ import com.nalssilog.report.domain.WeatherReport; import com.querydsl.core.Tuple; import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.DateTimeExpression; import com.querydsl.core.types.dsl.EnumPath; import com.querydsl.core.types.dsl.NumberExpression; import com.querydsl.jpa.impl.JPAQueryFactory; @@ -39,6 +41,7 @@ public class WeatherReportRepository { private final EntityManager entityManager; public ReportData save(WeatherReport report) { + return ReportData.of(weatherReportJpaRepository.save(report)); } @@ -47,6 +50,7 @@ public ReportData save(WeatherReport report) { */ public int anonymizeAuthor(Long memberId) { entityManager.flush(); + long affectedRows = queryFactory .update(weatherReport) .set(weatherReport.authorType, ActorType.ANONYMOUS) @@ -57,17 +61,20 @@ public int anonymizeAuthor(Long memberId) { weatherReport.authorMemberId.eq(memberId) ) .execute(); + entityManager.clear(); return Math.toIntExact(affectedRows); } public ReportData getReport(Long reportId) { + return ReportData.of(getReportEntity(reportId)); } /** 삭제처럼 관리 엔티티가 필요한 쓰기 유스케이스 전용. 반드시 트랜잭션 안에서 사용한다. */ public WeatherReport getReportEntity(Long reportId) { + return weatherReportJpaRepository.findById(reportId) .orElseThrow(() -> new NalssiLogException(ReportErrorCode.REPORT_NOT_FOUND)); } @@ -99,16 +106,41 @@ public List findMemberPage(Long memberId, Instant cursorTime, Long c .toList(); } - /** 최근({@code since} 이후) 제보 수가 많은 순으로 상위 locationId 목록(인기 지역 랭킹용). */ - public List topLocationIds(Instant since, int size) { - return queryFactory - .select(weatherReport.locationId) + public List findPopularLocationAggregates( + Instant windowStartedAt, + Instant windowEndedAt, + int limit + ) { + NumberExpression uniqueReporterCount = weatherReport.authorMemberId.countDistinct() + .add(weatherReport.authorAnonymousKey.countDistinct()); + NumberExpression reportCount = weatherReport.id.count(); + DateTimeExpression latestReportAt = weatherReport.createdAt.max(); + List rows = queryFactory + .select( + weatherReport.locationId, + uniqueReporterCount, + reportCount, + latestReportAt) .from(weatherReport) - .where(weatherReport.createdAt.goe(since)) + .where( + weatherReport.createdAt.goe(windowStartedAt), + weatherReport.createdAt.lt(windowEndedAt)) .groupBy(weatherReport.locationId) - .orderBy(weatherReport.id.count().desc()) - .limit(size) + .orderBy( + uniqueReporterCount.desc(), + reportCount.desc(), + latestReportAt.desc(), + weatherReport.locationId.asc()) + .limit(limit) .fetch(); + + return rows.stream() + .map(row -> new PopularLocationAggregate( + row.get(weatherReport.locationId), + value(row.get(uniqueReporterCount)), + value(row.get(reportCount)), + row.get(latestReportAt))) + .toList(); } /** 최근({@code since} 이후) 제보의 3축 분포 + 제보 수 집계. */ @@ -126,6 +158,7 @@ public WeatherStatsData statsSince(Long locationId, Instant since) { private List findAfterLocationCursor( Long locationId, Instant cursorTime, Long cursorId, int limit) { + return queryFactory .selectFrom(weatherReport) .where( @@ -139,6 +172,7 @@ private List findAfterLocationCursor( private List findAfterMemberCursor( Long memberId, Instant cursorTime, Long cursorId, int limit) { + return queryFactory .selectFrom(weatherReport) .where( @@ -152,6 +186,7 @@ private List findAfterMemberCursor( } private BooleanExpression beforeCursor(Instant cursorTime, Long cursorId) { + return weatherReport.createdAt.lt(cursorTime) .or(weatherReport.createdAt.eq(cursorTime).and(weatherReport.id.lt(cursorId))); } @@ -170,8 +205,14 @@ private > Map countByAxis( .fetch(); Map counts = new EnumMap<>(type); + rows.forEach(row -> counts.put(row.get(axis), row.get(count))); return counts; } + + private static long value(Long value) { + + return value == null ? 0 : value; + } } diff --git a/report/src/test/java/com/nalssilog/report/application/PopularLocationSnapshotServiceTest.java b/report/src/test/java/com/nalssilog/report/application/PopularLocationSnapshotServiceTest.java new file mode 100644 index 0000000..1437846 --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/application/PopularLocationSnapshotServiceTest.java @@ -0,0 +1,229 @@ +package com.nalssilog.report.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +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.location.config.PopularLocationProperties; +import com.nalssilog.location.domain.PopularRankMovement; +import com.nalssilog.report.application.dto.PopularLocationAggregate; +import com.nalssilog.report.domain.PopularLocationRank; +import com.nalssilog.report.domain.PopularLocationSnapshot; +import com.nalssilog.report.repository.PopularLocationRankJpaRepository; +import com.nalssilog.report.repository.PopularLocationSnapshotJpaRepository; +import com.nalssilog.report.repository.WeatherReportRepository; +import java.time.Duration; +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.test.util.ReflectionTestUtils; + +@SuppressWarnings("java:S5960") +class PopularLocationSnapshotServiceTest { + + private static final PopularLocationProperties PROPERTIES = + new PopularLocationProperties( + Duration.ofDays(7), + 20, + Duration.ofMinutes(10)); + + private final WeatherReportRepository reportRepository = + mock(WeatherReportRepository.class); + private final PopularLocationSnapshotJpaRepository snapshotRepository = + mock(PopularLocationSnapshotJpaRepository.class); + private final PopularLocationRankJpaRepository rankRepository = + mock(PopularLocationRankJpaRepository.class); + private final PopularLocationSnapshotService service = + new PopularLocationSnapshotService( + reportRepository, + snapshotRepository, + rankRepository, + PROPERTIES); + + @Test + void createsFirstSnapshotWithNewMovementsAndDetailedMetrics() { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + Instant windowStartedAt = calculatedAt.minus(Duration.ofDays(7)); + + when(snapshotRepository.findFirstByOrderByCalculatedAtDescIdDesc()) + .thenReturn(Optional.empty()); + when(reportRepository.findPopularLocationAggregates(windowStartedAt, calculatedAt, 20)) + .thenReturn(List.of( + new PopularLocationAggregate( + 11L, + 4, + 7, + calculatedAt.minusSeconds(60)), + new PopularLocationAggregate( + 12L, + 2, + 3, + calculatedAt.minusSeconds(120)))); + assignSnapshotId(100L); + + var result = service.captureAt(calculatedAt); + + assertThat(result.snapshotId()).isEqualTo(100L); + assertThat(result.windowStartedAt()).isEqualTo(windowStartedAt); + assertThat(result.rankings()).hasSize(2); + assertThat(result.rankings().getFirst().rank()).isEqualTo(1); + assertThat(result.rankings().getFirst().previousRank()).isNull(); + assertThat(result.rankings().getFirst().rankChange()).isNull(); + assertThat(result.rankings().getFirst().movement()).isEqualTo(PopularRankMovement.NEW); + assertThat(result.rankings().getFirst().uniqueReporterCount()).isEqualTo(4); + assertThat(result.rankings().getFirst().reportCount()).isEqualTo(7); + verify(rankRepository).saveAll(any()); + } + + @Test + void comparesNewSnapshotWithPreviousRanking() { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + PopularLocationSnapshot previous = snapshot( + 90L, + calculatedAt.minus(Duration.ofMinutes(10))); + List previousRanks = List.of( + rank(90L, 11L, 1), + rank(90L, 12L, 2)); + + when(snapshotRepository.findFirstByOrderByCalculatedAtDescIdDesc()) + .thenReturn(Optional.of(previous)); + when(rankRepository.findAllBySnapshotIdOrderByPositionAsc(90L)) + .thenReturn(previousRanks); + when(reportRepository.findPopularLocationAggregates( + calculatedAt.minus(Duration.ofDays(7)), + calculatedAt, + 20)) + .thenReturn(List.of( + aggregate(12L, calculatedAt), + aggregate(11L, calculatedAt), + aggregate(13L, calculatedAt))); + assignSnapshotId(100L); + + var result = service.captureAt(calculatedAt); + + assertThat(result.rankings()) + .extracting( + ranking -> ranking.locationId(), + ranking -> ranking.previousRank(), + ranking -> ranking.rankChange(), + ranking -> ranking.movement()) + .containsExactly( + org.assertj.core.groups.Tuple.tuple( + 12L, 2, 1, PopularRankMovement.UP), + org.assertj.core.groups.Tuple.tuple( + 11L, 1, -1, PopularRankMovement.DOWN), + org.assertj.core.groups.Tuple.tuple( + 13L, null, null, PopularRankMovement.NEW)); + } + + @Test + void reusesFreshSnapshotWithoutRunningAggregation() { + Instant now = Instant.parse("2026-07-30T06:00:00Z"); + PopularLocationSnapshot latest = snapshot( + 100L, + now.minus(Duration.ofMinutes(5))); + PopularLocationRank storedRank = rank(100L, 11L, 1); + + when(snapshotRepository.findFirstByOrderByCalculatedAtDescIdDesc()) + .thenReturn(Optional.of(latest)); + when(rankRepository.findAllBySnapshotIdOrderByPositionAsc(100L)) + .thenReturn(List.of(storedRank)); + + var result = service.latestOrRefreshAt(now); + + assertThat(result.snapshotId()).isEqualTo(100L); + assertThat(result.rankings()).singleElement() + .satisfies(ranking -> assertThat(ranking.locationId()).isEqualTo(11L)); + verify(reportRepository, never()) + .findPopularLocationAggregates(any(), any(), anyInt()); + verify(snapshotRepository, never()).save(any()); + } + + @Test + void startsNewMovementBaselineWhenAlgorithmChanges() { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + PopularLocationSnapshot previous = snapshot( + 90L, + calculatedAt.minus(Duration.ofMinutes(10)), + "RECENT_ACTIVITY_V0"); + + when(snapshotRepository.findFirstByOrderByCalculatedAtDescIdDesc()) + .thenReturn(Optional.of(previous)); + when(reportRepository.findPopularLocationAggregates( + calculatedAt.minus(Duration.ofDays(7)), + calculatedAt, + 20)) + .thenReturn(List.of(aggregate(11L, calculatedAt))); + assignSnapshotId(100L); + + var result = service.captureAt(calculatedAt); + + assertThat(result.rankings()).singleElement() + .satisfies(ranking -> { + assertThat(ranking.previousRank()).isNull(); + assertThat(ranking.rankChange()).isNull(); + assertThat(ranking.movement()).isEqualTo(PopularRankMovement.NEW); + }); + verify(rankRepository, never()).findAllBySnapshotIdOrderByPositionAsc(90L); + } + + private void assignSnapshotId(Long id) { + when(snapshotRepository.save(any(PopularLocationSnapshot.class))) + .thenAnswer(invocation -> { + PopularLocationSnapshot snapshot = invocation.getArgument(0); + + ReflectionTestUtils.setField(snapshot, "id", id); + + return snapshot; + }); + } + + private static PopularLocationSnapshot snapshot(Long id, Instant calculatedAt) { + return snapshot(id, calculatedAt, "UNIQUE_REPORTERS_V1"); + } + + private static PopularLocationSnapshot snapshot( + Long id, + Instant calculatedAt, + String algorithmVersion + ) { + PopularLocationSnapshot snapshot = PopularLocationSnapshot.create( + calculatedAt, + calculatedAt.minus(Duration.ofDays(7)), + calculatedAt, + 20, + algorithmVersion); + + ReflectionTestUtils.setField(snapshot, "id", id); + + return snapshot; + } + + private static PopularLocationRank rank(Long snapshotId, Long locationId, int position) { + + return PopularLocationRank.create( + snapshotId, + locationId, + position, + position, + 0, + PopularRankMovement.SAME, + 1, + 1, + Instant.parse("2026-07-30T05:00:00Z")); + } + + private static PopularLocationAggregate aggregate(Long locationId, Instant calculatedAt) { + + return new PopularLocationAggregate( + locationId, + 1, + 1, + calculatedAt.minusSeconds(60)); + } +} diff --git a/report/src/test/java/com/nalssilog/report/application/ReportPopularLocationSourceTest.java b/report/src/test/java/com/nalssilog/report/application/ReportPopularLocationSourceTest.java new file mode 100644 index 0000000..59fe111 --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/application/ReportPopularLocationSourceTest.java @@ -0,0 +1,38 @@ +package com.nalssilog.report.application; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.location.application.dto.PopularLocationSnapshotData; +import java.time.Instant; +import java.util.List; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class ReportPopularLocationSourceTest { + + private final PopularLocationSnapshotService snapshotService = + mock(PopularLocationSnapshotService.class); + private final ReportPopularLocationSource source = + new ReportPopularLocationSource(snapshotService); + + @Test + void returnsLatestPersistedSnapshot() { + Instant calculatedAt = Instant.parse("2026-07-30T06:00:00Z"); + PopularLocationSnapshotData snapshot = new PopularLocationSnapshotData( + 10L, + calculatedAt, + calculatedAt.minusSeconds(60), + calculatedAt, + "UNIQUE_REPORTERS_V1", + List.of()); + + when(snapshotService.latestOrRefreshAt(any(Instant.class))).thenReturn(snapshot); + + assertThat(source.latestSnapshot()).isSameAs(snapshot); + verify(snapshotService).latestOrRefreshAt(any(Instant.class)); + } +} From 3e0a8101e5022959f4660e03680c7d7716aa469d Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Thu, 30 Jul 2026 19:12:13 +0900 Subject: [PATCH 3/7] =?UTF-8?q?chore:=20Apple=20OAuth=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=ED=95=84=EA=B3=BC=20config=20=EB=B2=84=EC=A0=84=20=EB=B0=98?= =?UTF-8?q?=EC=98=81=20-=20=EA=B0=9C=EB=B0=9C=EA=B3=BC=20=EC=9A=B4?= =?UTF-8?q?=EC=98=81=20=EB=B0=B0=ED=8F=AC=EC=97=90=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=84=20Apple=20OAuth=20secret=20=EC=A0=84=EB=8B=AC?= =?UTF-8?q?=EC=9D=84=20=EC=B6=94=EA=B0=80=20-=20Apple=20client,=20team,=20?= =?UTF-8?q?key=EC=99=80=20private=20key=20=EC=84=A4=EC=A0=95=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EA=B2=80=EC=A6=9D=20-=20Apple=20secret=EC=9D=B4=20?= =?UTF-8?q?=EC=9D=BC=EB=B6=80=EB=A7=8C=20=EC=84=A4=EC=A0=95=EB=90=9C=20?= =?UTF-8?q?=EA=B2=BD=EC=9A=B0=20=EB=B0=B0=ED=8F=AC=20=EC=A4=91=EB=8B=A8=20?= =?UTF-8?q?-=20Apple=20=EC=84=A4=EC=A0=95=EC=9D=B4=20=EB=AA=A8=EB=91=90=20?= =?UTF-8?q?=EC=A1=B4=EC=9E=AC=ED=95=A0=20=EB=95=8C=EB=A7=8C=20apple=20Spri?= =?UTF-8?q?ng=20profile=20=ED=99=9C=EC=84=B1=ED=99=94=20-=20Compose?= =?UTF-8?q?=EA=B0=80=20=EC=A3=BC=EC=9E=85=EB=90=9C=20SPRING=5FPROFILES=5FA?= =?UTF-8?q?CTIVE=EB=A5=BC=20=EC=82=AC=EC=9A=A9=ED=95=98=EB=8F=84=EB=A1=9D?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20-=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EC=BD=9C=EB=B0=B1=EA=B3=BC=20=EC=9D=B8=EA=B8=B0=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=20=EC=A0=95=EC=B1=85=EC=9D=B4=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=EB=90=9C=20config=20=EC=84=9C=EB=B8=8C=EB=AA=A8=EB=93=88=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/cd-dev.yml | 26 +++++++++++++++++++++++++- .github/workflows/cd-prod.yml | 26 +++++++++++++++++++++++++- config | 2 +- deploy/compose-dev.yml | 2 +- deploy/compose-prod.yml | 2 +- 5 files changed, 53 insertions(+), 5 deletions(-) diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index e5358f4..75c2688 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -135,6 +135,10 @@ jobs: OAUTH_GOOGLE_CLIENT_SECRET: ${{ secrets.OAUTH_GOOGLE_CLIENT_SECRET }} OAUTH_KAKAO_CLIENT_ID: ${{ secrets.OAUTH_KAKAO_CLIENT_ID }} OAUTH_KAKAO_CLIENT_SECRET: ${{ secrets.OAUTH_KAKAO_CLIENT_SECRET }} + APPLE_CLIENT_ID: ${{ secrets.DEV_OAUTH_APPLE_CLIENT_ID }} + APPLE_TEAM_ID: ${{ secrets.DEV_OAUTH_APPLE_TEAM_ID }} + APPLE_KEY_ID: ${{ secrets.DEV_OAUTH_APPLE_KEY_ID }} + APPLE_PRIVATE_KEY_BASE64: ${{ secrets.DEV_OAUTH_APPLE_PRIVATE_KEY_BASE64 }} DEV_R2_PUBLIC_BASE_URL: ${{ secrets.DEV_R2_PUBLIC_BASE_URL }} DEV_R2_ENDPOINT: ${{ secrets.DEV_R2_ENDPOINT }} DEV_R2_BUCKET: ${{ secrets.DEV_R2_BUCKET }} @@ -144,7 +148,7 @@ jobs: host: ${{ secrets.EC2_HOST_DEV }} username: ${{ secrets.EC2_USER_DEV }} key: ${{ secrets.EC2_SSH_KEY_DEV }} - envs: GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,DEV_R2_PUBLIC_BASE_URL,DEV_R2_ENDPOINT,DEV_R2_BUCKET,DEV_R2_ACCESS_KEY,DEV_R2_SECRET_KEY + envs: GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY_BASE64,DEV_R2_PUBLIC_BASE_URL,DEV_R2_ENDPOINT,DEV_R2_BUCKET,DEV_R2_ACCESS_KEY,DEV_R2_SECRET_KEY script: | set -eu cd /home/ubuntu/nalssilog-dev-api @@ -154,8 +158,24 @@ jobs: exit 1 fi done + APPLE_VALUE_COUNT=0 + for name in APPLE_CLIENT_ID APPLE_TEAM_ID APPLE_KEY_ID APPLE_PRIVATE_KEY_BASE64; do + if [ -n "$(printenv "$name" 2>/dev/null || true)" ]; then + APPLE_VALUE_COUNT=$((APPLE_VALUE_COUNT + 1)) + fi + done + if [ "$APPLE_VALUE_COUNT" -ne 0 ] && [ "$APPLE_VALUE_COUNT" -ne 4 ]; then + echo "Apple OAuth secrets must be configured all together." + exit 1 + fi + ACTIVE_PROFILES=dev + if [ "$APPLE_VALUE_COUNT" -eq 4 ]; then + ACTIVE_PROFILES=dev,apple + fi + export SPRING_PROFILES_ACTIVE=$ACTIVE_PROFILES umask 077 cat > .env.dev </dev/null || true)" ]; then + APPLE_VALUE_COUNT=$((APPLE_VALUE_COUNT + 1)) + fi + done + if [ "$APPLE_VALUE_COUNT" -ne 0 ] && [ "$APPLE_VALUE_COUNT" -ne 4 ]; then + echo "Apple OAuth secrets must be configured all together." + exit 1 + fi + ACTIVE_PROFILES=prod + if [ "$APPLE_VALUE_COUNT" -eq 4 ]; then + ACTIVE_PROFILES=prod,apple + fi + export SPRING_PROFILES_ACTIVE=$ACTIVE_PROFILES umask 077 cat > .env.prod < Date: Thu, 30 Jul 2026 23:42:20 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=EA=B3=84=EC=A0=95=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=ED=9B=84=20=EC=8B=A0=EA=B7=9C=20=EC=86=8C=EC=85=9C?= =?UTF-8?q?=EC=9D=84=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=88=98=EB=8B=A8?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EA=B8=B0=EB=A1=9D=20-=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20=EA=B3=84=EC=A0=95=20=EC=86=8C=EC=9C=A0=EA=B6=8C=20?= =?UTF-8?q?=ED=99=95=EC=9D=B8=EC=97=90=20=EC=82=AC=EC=9A=A9=ED=95=9C=20?= =?UTF-8?q?=EC=9E=AC=EC=9D=B8=EC=A6=9D=20provider=EC=99=80=20=EC=B5=9C?= =?UTF-8?q?=EC=A2=85=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20provider=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20-=20=EC=9B=B9=20=EA=B3=84=EC=A0=95=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20=EC=99=84=EB=A3=8C=20=EC=8B=9C=20=EC=8B=A0=EA=B7=9C?= =?UTF-8?q?=20=EC=86=8C=EC=85=9C=20provider=EB=A1=9C=20AT=EC=99=80=20RT=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20-=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=EA=B3=84=EC=A0=95=20=EC=97=B0=EB=8F=99=20=EC=99=84=EB=A3=8C=20?= =?UTF-8?q?=EB=B0=8F=20code=20=EA=B5=90=ED=99=98=20=EC=8B=9C=20=EC=8B=A0?= =?UTF-8?q?=EA=B7=9C=20=EC=86=8C=EC=85=9C=20provider=EB=A1=9C=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0=20=EB=B0=9C=EA=B8=89=20-=20OAuth=20=EC=8B=A0=EC=9B=90?= =?UTF-8?q?=20=ED=99=95=EC=9D=B8=20=EB=8B=A8=EA=B3=84=EC=97=90=EC=84=9C?= =?UTF-8?q?=EB=8A=94=20lastLoginAt=20=EA=B0=B1=EC=8B=A0=ED=95=98=EC=A7=80?= =?UTF-8?q?=20=EC=95=8A=EC=9D=8C=20-=20=EC=9B=B9=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=EA=B3=BC=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20cod?= =?UTF-8?q?e=20=EA=B5=90=ED=99=98=20=EC=8B=9C=20=EC=B5=9C=EC=A2=85=20provi?= =?UTF-8?q?der=EC=9D=98=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=EA=B0=81?= =?UTF-8?q?=20=EA=B8=B0=EB=A1=9D=20-=20=EC=84=A4=EC=A0=95=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=9D=98=20=EB=8B=A8=EC=88=9C=20=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=EA=B3=BC=20=EC=97=B0=EB=8F=99=20=EC=9E=AC?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EC=9D=80=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8=20-?= =?UTF-8?q?=20refresh=EB=8A=94=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=81=EC=9D=84=20=EA=B0=B1=EC=8B=A0=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8A=94=20=EA=B8=B0=EC=A1=B4=20=EC=A0=95=EC=B1=85=20?= =?UTF-8?q?=EC=9C=A0=EC=A7=80=20-=20=EC=9B=B9/=EB=AA=A8=EB=B0=94=EC=9D=BC?= =?UTF-8?q?=20=EC=97=B0=EB=8F=99=20provider=EC=99=80=20=EC=86=8C=EC=85=9C?= =?UTF-8?q?=20=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=8B=9C=EA=B0=81=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=ED=85=8C=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/member/MemberClient.java | 4 + .../auth/mobile/oauth/MobileOAuthService.java | 4 +- .../nalssilog/auth/oauth/WebOAuthService.java | 4 +- .../mobile/oauth/MobileOAuthServiceTest.java | 117 ++++++++++++++++++ .../auth/oauth/WebOAuthServiceTest.java | 117 ++++++++++++++++++ .../member/api/dto/SocialAccountResponse.java | 2 +- .../application/MemberAccountService.java | 22 +++- .../member/domain/SocialAccount.java | 8 +- .../application/MemberAccountServiceTest.java | 65 ++++++++++ 9 files changed, 335 insertions(+), 8 deletions(-) create mode 100644 auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java create mode 100644 member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java diff --git a/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java b/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java index 6a23afd..242d647 100644 --- a/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java +++ b/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java @@ -45,6 +45,10 @@ public MemberInfo getMemberInfo(Long memberId) { return memberAccountService.getMemberInfo(memberId); } + public void recordLogin(Long memberId, Provider provider) { + memberAccountService.recordLogin(memberId, provider); + } + public Optional findMemberInfo(Long memberId) { return memberAccountService.findMemberInfo(memberId); diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java index be80f2e..a586721 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java @@ -293,7 +293,7 @@ private MobileOAuthGrant completeLoginLink( return MobileOAuthGrant.linkSuccess( member.id(), - principal.userInfo().provider(), + ticket.provider(), true); } catch (NalssiLogException _) { @@ -344,6 +344,8 @@ private ExchangeResult authenticated( grant.provider(), device); + memberClient.recordLogin(member.id(), grant.provider()); + return new ExchangeResult( result, tokens, member, null, null, null, null, List.of()); } diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java b/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java index 7efe396..4c7a020 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java @@ -147,7 +147,7 @@ private Completion completeLoginLink( return authenticated( member.id(), member.status(), - principal.userInfo().provider(), + ticket.provider(), LINK_SUCCESS, device, true); @@ -201,6 +201,8 @@ private Completion authenticated( provider, device); + memberClient.recordLogin(memberId, provider); + return new Completion( result, null, diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java index c8bbb0f..38701e7 100644 --- a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java +++ b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java @@ -15,7 +15,9 @@ import com.nalssilog.auth.member.MemberClient; import com.nalssilog.auth.oauth.OAuthUserInfo; import com.nalssilog.auth.oauth.SocialAuthPrincipal; +import com.nalssilog.auth.ticket.AuthChannel; import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.ticket.LinkTicket; import com.nalssilog.auth.token.AuthTokenService; import com.nalssilog.auth.token.TokenPair; import com.nalssilog.common.exception.NalssiLogException; @@ -187,6 +189,107 @@ void tokensAreCreatedOnlyAfterSuccessfulCodeExchange() { MemberStatus.ACTIVE, Provider.KAKAO, device); + verify(memberClient).recordLogin(7L, Provider.KAKAO); + } + + @Test + void loginLinkIssuesSessionForNewlyLinkedProvider() { + String verifier = "v".repeat(43); + DeviceInfo device = new DeviceInfo( + "IOS · iPhone · 0.1.0", + "203.0.113.10"); + MobileOAuthTransaction transaction = new MobileOAuthTransaction( + MobileOAuthPurpose.LOGIN_LINK_REAUTH, + Provider.NAVER, + REDIRECT_URI, + CHALLENGE, + STATE, + "link-ticket", + 7L); + LinkTicket ticket = new LinkTicket( + Provider.KAKAO, + "new-kakao-user", + "user@example.com", + 7L, + List.of(Provider.NAVER), + AuthChannel.MOBILE); + OAuthUserInfo linkedUserInfo = new OAuthUserInfo( + Provider.KAKAO, + "new-kakao-user", + "user@example.com", + null); + SocialAuthPrincipal reauthenticatedPrincipal = new SocialAuthPrincipal( + SocialLoginResult.existing(7L, MemberStatus.ACTIVE), + new OAuthUserInfo( + Provider.NAVER, + "existing-naver-user", + "user@example.com", + "사용자"), + Map.of()); + MemberInfo member = linkedMember(); + TokenPair tokens = new TokenPair( + "access-token", + "refresh-token", + Duration.ofDays(14)); + + when(transactionStore.take("transaction")) + .thenReturn(Optional.of(transaction)); + when(ticketStore.findLink("link-ticket")).thenReturn(Optional.of(ticket)); + when(ticketStore.isLinkConsented("link-ticket")).thenReturn(true); + when(memberClient.linkSocial(7L, linkedUserInfo)).thenReturn(member); + + service.complete("transaction", reauthenticatedPrincipal); + + verify(memberClient, never()).recordLogin(any(), any()); + + ArgumentCaptor grantCaptor = + ArgumentCaptor.forClass(MobileOAuthGrant.class); + + verify(codeStore).save( + any(), + grantCaptor.capture(), + org.mockito.ArgumentMatchers.eq(REDIRECT_URI), + org.mockito.ArgumentMatchers.eq(CHALLENGE)); + + MobileOAuthGrant grant = grantCaptor.getValue(); + + assertThat(grant).isEqualTo(MobileOAuthGrant.linkSuccess( + 7L, + Provider.KAKAO, + true)); + + when(codeStore.consume( + org.mockito.ArgumentMatchers.eq("one-time-code"), + org.mockito.ArgumentMatchers.eq(REDIRECT_URI), + any())).thenReturn(grant); + when(memberClient.getMemberInfo(7L)).thenReturn(member); + when(authTokenService.issue( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + device)).thenReturn(tokens); + + MobileOAuthService.ExchangeResult result = service.exchange( + "one-time-code", + verifier, + REDIRECT_URI, + device); + + assertThat(result.result()).isEqualTo(MobileAuthResult.LINK_SUCCESS); + assertThat(result.tokens()).isEqualTo(tokens); + + verify(authTokenService).issue( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + device); + verify(authTokenService, never()).issue( + 7L, + MemberStatus.ACTIVE, + Provider.NAVER, + device); + verify(memberClient).recordLogin(7L, Provider.KAKAO); + verify(memberClient, never()).recordLogin(7L, Provider.NAVER); } @Test @@ -219,6 +322,20 @@ private MemberInfo member() { List.of(Provider.KAKAO)); } + private MemberInfo linkedMember() { + + return new MemberInfo( + 7L, + "사용자", + "구름산책", + "user@example.com", + AvatarType.PRESET, + "1", + MemberStatus.ACTIVE, + Provider.KAKAO, + List.of(Provider.NAVER, Provider.KAKAO)); + } + private AuthProperties properties() { return new AuthProperties( diff --git a/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java new file mode 100644 index 0000000..e969c97 --- /dev/null +++ b/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java @@ -0,0 +1,117 @@ +package com.nalssilog.auth.oauth; + +import static org.assertj.core.api.Assertions.assertThat; +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.config.AuthProperties; +import com.nalssilog.auth.device.DeviceInfo; +import com.nalssilog.auth.member.MemberClient; +import com.nalssilog.auth.ticket.AuthChannel; +import com.nalssilog.auth.ticket.AuthTicketStore; +import com.nalssilog.auth.ticket.LinkTicket; +import com.nalssilog.auth.token.AuthTokenService; +import com.nalssilog.auth.token.TokenPair; +import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.application.dto.SocialLoginResult; +import com.nalssilog.member.domain.AvatarType; +import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.Provider; +import java.time.Duration; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class WebOAuthServiceTest { + + private final AuthTokenService authTokenService = mock(AuthTokenService.class); + private final AuthTicketStore ticketStore = mock(AuthTicketStore.class); + private final MemberClient memberClient = mock(MemberClient.class); + private final WebOAuthService service = new WebOAuthService( + authTokenService, + ticketStore, + memberClient, + mock(AuthProperties.class)); + + @Test + void loginLinkIssuesSessionForNewlyLinkedProvider() { + DeviceInfo device = new DeviceInfo("Chrome", "203.0.113.10"); + LinkTicket ticket = new LinkTicket( + Provider.KAKAO, + "new-kakao-user", + "user@example.com", + 7L, + List.of(Provider.NAVER), + AuthChannel.WEB); + OAuthUserInfo linkedUserInfo = new OAuthUserInfo( + Provider.KAKAO, + "new-kakao-user", + "user@example.com", + null); + SocialAuthPrincipal reauthenticatedPrincipal = new SocialAuthPrincipal( + SocialLoginResult.existing(7L, MemberStatus.ACTIVE), + new OAuthUserInfo( + Provider.NAVER, + "existing-naver-user", + "user@example.com", + "사용자"), + Map.of()); + MemberInfo member = member(); + TokenPair tokens = new TokenPair( + "access-token", + "refresh-token", + Duration.ofDays(14)); + + when(ticketStore.findLink("link-ticket")).thenReturn(Optional.of(ticket)); + when(ticketStore.isLinkConsented("link-ticket")).thenReturn(true); + when(memberClient.linkSocial(7L, linkedUserInfo)).thenReturn(member); + when(authTokenService.issue( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + device)).thenReturn(tokens); + + WebOAuthService.Completion completion = service.complete( + reauthenticatedPrincipal, + Optional.empty(), + Optional.of("link-ticket"), + device); + + assertThat(completion.result()).isEqualTo("LINK_SUCCESS"); + assertThat(completion.tokens()).isEqualTo(tokens); + assertThat(completion.clearLinkTicket()).isTrue(); + + verify(authTokenService).issue( + 7L, + MemberStatus.ACTIVE, + Provider.KAKAO, + device); + verify(authTokenService, never()).issue( + 7L, + MemberStatus.ACTIVE, + Provider.NAVER, + device); + verify(memberClient).recordLogin(7L, Provider.KAKAO); + verify(memberClient, never()).recordLogin(7L, Provider.NAVER); + verify(ticketStore).deleteLink("link-ticket"); + verify(ticketStore).deleteLinkConsent("link-ticket"); + } + + private MemberInfo member() { + + return new MemberInfo( + 7L, + "구름산책", + "사용자", + "user@example.com", + AvatarType.PRESET, + "1", + MemberStatus.ACTIVE, + Provider.KAKAO, + List.of(Provider.NAVER, Provider.KAKAO)); + } +} diff --git a/member/src/main/java/com/nalssilog/member/api/dto/SocialAccountResponse.java b/member/src/main/java/com/nalssilog/member/api/dto/SocialAccountResponse.java index 10f33ff..e8520c5 100644 --- a/member/src/main/java/com/nalssilog/member/api/dto/SocialAccountResponse.java +++ b/member/src/main/java/com/nalssilog/member/api/dto/SocialAccountResponse.java @@ -4,7 +4,7 @@ import com.nalssilog.member.domain.Provider; import java.time.Instant; -/** 추가 연동 후 아직 해당 제공자로 로그인하지 않았다면 lastLoginAt 은 null. */ +/** 설정에서 추가 연동한 뒤 아직 해당 제공자로 로그인하지 않았다면 lastLoginAt 은 null. */ public record SocialAccountResponse(Provider provider, String email, Instant lastLoginAt) { public static SocialAccountResponse from(SocialAccountInfo info) { diff --git a/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java b/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java index e6f8b71..22dbc8d 100644 --- a/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java +++ b/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java @@ -26,7 +26,6 @@ public class MemberAccountService { private final ApplicationEventPublisher eventPublisher; /** 소셜 인증 결과 분기(생성·병합 안 함). 가입된 소셜=EXISTING, 이메일로 기존 회원 있으면 LINK_REQUIRED, 없으면 NEW. */ - @Transactional public SocialLoginResult resolveSocialLogin(Provider provider, String providerUserId, String email) { Optional linked = socialAccountRepository.findByProviderAndProviderUserId( provider, @@ -35,8 +34,6 @@ public SocialLoginResult resolveSocialLogin(Provider provider, String providerUs if (linked.isPresent()) { SocialAccount account = linked.get(); - account.touchLogin(); - return SocialLoginResult.existing(account.getMember().getId(), account.getMember().getStatus()); } @@ -52,7 +49,13 @@ public SocialLoginResult resolveSocialLogin(Provider provider, String providerUs /** 기존 회원에 새 소셜 계정 연동(호출 전 재인증으로 소유권 증명 전제). */ @Transactional - public MemberInfo linkSocial(Long targetMemberId, Provider provider, String providerUserId, String email) { + public MemberInfo linkSocial( + Long targetMemberId, + Provider provider, + String providerUserId, + String email + ) { + if (socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId).isPresent()) { throw new NalssiLogException(MemberErrorCode.SOCIAL_ACCOUNT_IN_USE); } @@ -64,6 +67,17 @@ public MemberInfo linkSocial(Long targetMemberId, Provider provider, String prov return memberRepository.getMemberInfo(targetMemberId); } + /** 최종 로그인 세션 발급에 사용한 소셜 계정의 로그인 시각만 갱신한다. */ + @Transactional + public void recordLogin(Long memberId, Provider provider) { + SocialAccount account = socialAccountRepository + .findByMemberIdAndProvider(memberId, provider) + .orElseThrow(() -> new NalssiLogException( + MemberErrorCode.SOCIAL_ACCOUNT_NOT_FOUND)); + + account.touchLogin(); + } + /** 회원 탈퇴. 익명화 + 소셜 삭제 + 제보 익명화용 MemberWithdrawnEvent 발행. 세션·쿠키 정리는 auth. */ @Transactional public void withdraw(Long memberId) { diff --git a/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java b/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java index f6af53d..f7f9c83 100644 --- a/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java +++ b/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java @@ -60,7 +60,13 @@ public static SocialAccount register(Member member, Provider provider, String pr } /** 추가 연동은 로그인이 아니므로 실제로 이 제공자로 로그인하기 전까지 로그인 시각을 비워 둔다. */ - public static SocialAccount link(Member member, Provider provider, String providerUserId, String providerEmail) { + public static SocialAccount link( + Member member, + Provider provider, + String providerUserId, + String providerEmail + ) { + return create(member, provider, providerUserId, providerEmail); } diff --git a/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java b/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java new file mode 100644 index 0000000..ad9dfc6 --- /dev/null +++ b/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java @@ -0,0 +1,65 @@ +package com.nalssilog.member.application; + +import static org.assertj.core.api.Assertions.assertThat; +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.member.application.dto.SocialLoginResult; +import com.nalssilog.member.domain.Member; +import com.nalssilog.member.domain.MemberStatus; +import com.nalssilog.member.domain.Provider; +import com.nalssilog.member.domain.SocialAccount; +import com.nalssilog.member.repository.MemberRepository; +import com.nalssilog.member.repository.SocialAccountRepository; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.context.ApplicationEventPublisher; + +@SuppressWarnings("java:S5960") +class MemberAccountServiceTest { + + private final MemberRepository memberRepository = mock(MemberRepository.class); + private final SocialAccountRepository socialAccountRepository = + mock(SocialAccountRepository.class); + private final MemberAccountService service = new MemberAccountService( + memberRepository, + socialAccountRepository, + mock(ApplicationEventPublisher.class)); + + @Test + void resolvingExistingSocialIdentityDoesNotRecordServiceLogin() { + Member member = mock(Member.class); + SocialAccount account = mock(SocialAccount.class); + + when(socialAccountRepository.findByProviderAndProviderUserId( + Provider.NAVER, + "naver-user")).thenReturn(Optional.of(account)); + when(account.getMember()).thenReturn(member); + when(member.getId()).thenReturn(7L); + when(member.getStatus()).thenReturn(MemberStatus.ACTIVE); + + SocialLoginResult result = service.resolveSocialLogin( + Provider.NAVER, + "naver-user", + "user@example.com"); + + assertThat(result.outcome()).isEqualTo(SocialLoginResult.Outcome.EXISTING); + assertThat(result.memberId()).isEqualTo(7L); + verify(account, never()).touchLogin(); + } + + @Test + void finalServiceLoginRecordsSelectedSocialProvider() { + SocialAccount account = mock(SocialAccount.class); + + when(socialAccountRepository.findByMemberIdAndProvider( + 7L, + Provider.KAKAO)).thenReturn(Optional.of(account)); + + service.recordLogin(7L, Provider.KAKAO); + + verify(account).touchLogin(); + } +} From 2781072a438b968d3e8910e912f4f8d54a9c6356 Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Fri, 31 Jul 2026 14:19:12 +0900 Subject: [PATCH 5/7] =?UTF-8?q?fix:=20=EC=A1=B0=ED=9A=8C=20=EC=84=B1?= =?UTF-8?q?=EB=8A=A5=EA=B3=BC=20=EB=8F=99=EC=8B=9C=20=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EC=A0=95=ED=95=A9=EC=84=B1=20=EB=B3=B4=EA=B0=95=20-=20?= =?UTF-8?q?=EC=A0=9C=EB=B3=B4=20=EB=AA=A9=EB=A1=9D=EC=9D=98=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=20=EC=97=B0=EA=B4=80=EA=B4=80=EA=B3=84?= =?UTF-8?q?=EB=A5=BC=20=EB=A3=A8=ED=8A=B8=20=ED=8E=98=EC=9D=B4=EC=A7=95=20?= =?UTF-8?q?=ED=9B=84=20QueryDSL=20fetch=20join=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=84=20=EC=A1=B0=ED=9A=8C=20-=20=EC=BB=AC?= =?UTF-8?q?=EB=A0=89=EC=85=98=20fetch=20join=EC=9D=98=20=EB=A9=94=EB=AA=A8?= =?UTF-8?q?=EB=A6=AC=20=ED=8E=98=EC=9D=B4=EC=A7=95=EC=9D=84=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=ED=95=98=EA=B3=A0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20N+1=20=EC=A0=9C=EA=B1=B0=20-=20=EC=A0=9C?= =?UTF-8?q?=EB=B3=B4=20=EC=9E=91=EC=84=B1=EC=9E=90=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20ID=20=EB=AA=A9=EB=A1=9D=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=9D=BC=EA=B4=84=20=EC=A1=B0=ED=9A=8C=ED=95=B4=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=20=EC=A1=B0=ED=9A=8C=20N+1=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0=20-=20=EC=86=8C=EC=85=9C=20=EA=B3=84=EC=A0=95=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20member=20=EC=97=B0=EA=B4=80?= =?UTF-8?q?=EA=B4=80=EA=B3=84=EB=A5=BC=20QueryDSL=20fetch=20join=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=A1=9C=EB=94=A9=20-=20=EC=A6=90=EA=B2=A8?= =?UTF-8?q?=EC=B0=BE=EA=B8=B0=EC=99=80=20=EC=86=8C=EC=85=9C=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=EC=9D=98=20=EB=8F=99=EC=8B=9C=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A6=89=EC=8B=9C=20flush=ED=95=B4=20?= =?UTF-8?q?=EC=A0=9C=EC=95=BD=20=EC=9C=84=EB=B0=98=EC=9D=84=20=ED=8A=B8?= =?UTF-8?q?=EB=9E=9C=EC=9E=AD=EC=85=98=20=EC=95=88=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EA=B0=90=EC=A7=80=20-=20=EB=AA=85=EB=AA=85=EB=90=9C=20?= =?UTF-8?q?=EA=B3=A0=EC=9C=A0=20=EC=A0=9C=EC=95=BD=20=EC=B6=A9=EB=8F=8C?= =?UTF-8?q?=EC=9D=84=20FAVORITE=5FALREADY=5FEXISTS=20=EB=93=B1=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=EC=98=A4=EB=A5=98=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=ED=99=98=20-=20=ED=9A=8C=EC=9B=90=EB=B3=84=20=EC=86=8C?= =?UTF-8?q?=EC=85=9C=20=EC=A0=9C=EA=B3=B5=EC=9E=90=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=EA=B3=BC=20=EB=8F=99=EC=9D=BC=20=EC=9D=B8=EA=B8=B0=20=EC=8A=A4?= =?UTF-8?q?=EB=83=85=EC=83=B7=20=EC=A4=91=EB=B3=B5=20=EC=83=9D=EC=84=B1?= =?UTF-8?q?=EC=9D=84=20DB=20=EC=A0=9C=EC=95=BD=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20-=20DB=20=EB=B9=84=EA=B4=80=EC=A0=81=20?= =?UTF-8?q?=EC=9E=A0=EA=B8=88=EA=B3=BC=20=EA=B3=84=EC=82=B0=20=EA=B5=AC?= =?UTF-8?q?=EA=B0=84=20=EB=B2=84=ED=82=B7=EC=9C=BC=EB=A1=9C=20=EB=8B=A4?= =?UTF-8?q?=EC=A4=91=20=EC=9D=B8=EC=8A=A4=ED=84=B4=EC=8A=A4=EC=9D=98=20?= =?UTF-8?q?=EC=9D=B8=EA=B8=B0=20=EC=88=9C=EC=9C=84=20=EA=B0=B1=EC=8B=A0=20?= =?UTF-8?q?=EC=A7=81=EB=A0=AC=ED=99=94=20-=20=EB=8F=99=EC=8B=9C=EC=84=B1?= =?UTF-8?q?=20=EC=B6=A9=EB=8F=8C,=20=EC=8A=A4=EB=83=85=EC=83=B7=20?= =?UTF-8?q?=EB=A9=B1=EB=93=B1=EC=84=B1=20=EB=B0=8F=20=EC=9D=BC=EA=B4=84=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=ED=9A=8C=EA=B7=80=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=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 --- .../V11__harden_concurrent_writes.sql | 19 + .../application/LocationFavoriteService.java | 112 ++++-- .../location/domain/LocationErrorCode.java | 4 + .../LocationFavoriteServiceTest.java | 41 ++ .../application/MemberAccountService.java | 237 +++++++----- .../member/application/dto/MemberSummary.java | 27 ++ .../member/domain/SocialAccount.java | 8 +- .../member/repository/MemberRepository.java | 12 + .../SocialAccountJpaRepository.java | 2 - .../repository/SocialAccountRepository.java | 18 +- .../application/MemberAccountServiceTest.java | 67 ++++ .../PopularLocationSnapshotService.java | 364 ++++++++++-------- .../report/application/ReportService.java | 8 +- .../nalssilog/report/client/MemberClient.java | 27 +- .../domain/PopularLocationSnapshot.java | 11 +- .../domain/PopularLocationSnapshotLock.java | 23 ++ .../report/domain/ReportErrorCode.java | 4 + ...PopularLocationSnapshotLockRepository.java | 30 ++ .../repository/WeatherReportRepository.java | 59 ++- .../PopularLocationSnapshotServiceTest.java | 58 ++- .../report/application/ReportServiceTest.java | 45 +++ ...larLocationSnapshotLockRepositoryTest.java | 50 +++ 22 files changed, 909 insertions(+), 317 deletions(-) create mode 100644 app/src/main/resources/db/migration/V11__harden_concurrent_writes.sql create mode 100644 member/src/main/java/com/nalssilog/member/application/dto/MemberSummary.java create mode 100644 report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshotLock.java create mode 100644 report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepository.java create mode 100644 report/src/test/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepositoryTest.java diff --git a/app/src/main/resources/db/migration/V11__harden_concurrent_writes.sql b/app/src/main/resources/db/migration/V11__harden_concurrent_writes.sql new file mode 100644 index 0000000..fbc8b7f --- /dev/null +++ b/app/src/main/resources/db/migration/V11__harden_concurrent_writes.sql @@ -0,0 +1,19 @@ +ALTER TABLE social_account + ADD CONSTRAINT uk_social_account_member_provider + UNIQUE (member_id, provider); + +ALTER TABLE popular_location_snapshot + ADD CONSTRAINT uk_popular_location_snapshot_calculation + UNIQUE ( + calculated_at, + window_started_at, + ranking_limit, + algorithm_version + ); + +CREATE TABLE popular_location_snapshot_lock ( + id bigint PRIMARY KEY +); + +INSERT INTO popular_location_snapshot_lock (id) +VALUES (1); diff --git a/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java b/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java index 7866ec6..1e88e56 100644 --- a/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java +++ b/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java @@ -1,16 +1,23 @@ package com.nalssilog.location.application; +import java.util.List; + +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.common.response.PageResponse; import com.nalssilog.location.application.dto.LocationInfo; +import com.nalssilog.location.domain.LocationErrorCode; import com.nalssilog.location.domain.LocationFavorite; import com.nalssilog.location.repository.LocationFavoriteRepository; import com.nalssilog.location.repository.LocationRepository; -import java.util.List; + import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; /** * 회원 즐겨찾기 지역 관리. @@ -20,38 +27,65 @@ @Transactional(readOnly = true) public class LocationFavoriteService { - private static final int PAGE_SIZE = 5; - - private final LocationFavoriteRepository locationFavoriteRepository; - private final LocationRepository locationRepository; - - @Transactional - public void addFavorite(Long memberId, Long locationId) { - locationRepository.getById(locationId); - - if (!locationFavoriteRepository.existsByMemberIdAndLocationId(memberId, locationId)) { - locationFavoriteRepository.save(LocationFavorite.of(memberId, locationId)); - } - } - - @Transactional - public void removeFavorite(Long memberId, Long locationId) { - locationFavoriteRepository.deleteByMemberIdAndLocationId(memberId, locationId); - } - - public PageResponse listFavorites(Long memberId, int page) { - Page favorites = - locationFavoriteRepository.findAllByMemberIdOrderByCreatedAtDescIdDesc( - memberId, - PageRequest.of(page, PAGE_SIZE)); - List favoriteIds = favorites.getContent().stream() - .map(LocationFavorite::getLocationId) - .toList(); - - return PageResponse.of( - locationRepository.findByIds(favoriteIds), - page, - PAGE_SIZE, - favorites.getTotalElements()); - } + private static final int PAGE_SIZE = 5; + private static final String FAVORITE_CONSTRAINT = "uk_location_favorite_member_location"; + + private final LocationFavoriteRepository locationFavoriteRepository; + private final LocationRepository locationRepository; + + private static boolean hasConstraint( + Throwable throwable + ) { + for (Throwable cause = throwable; cause != null; cause = cause.getCause()) { + if (cause instanceof ConstraintViolationException constraintViolation) { + String constraintName = constraintViolation.getConstraintName(); + + return FAVORITE_CONSTRAINT.equalsIgnoreCase(constraintName); + } + } + + return false; + } + + @Transactional + public void addFavorite(Long memberId, Long locationId) { + locationRepository.getById(locationId); + + if (locationFavoriteRepository.existsByMemberIdAndLocationId(memberId, locationId)) { + return; + } + + try { + locationFavoriteRepository.saveAndFlush( + LocationFavorite.of(memberId, locationId)); + } catch (DataIntegrityViolationException exception) { + if (hasConstraint(exception)) { + throw new NalssiLogException( + LocationErrorCode.FAVORITE_ALREADY_EXISTS); + } + + throw exception; + } + } + + @Transactional + public void removeFavorite(Long memberId, Long locationId) { + locationFavoriteRepository.deleteByMemberIdAndLocationId(memberId, locationId); + } + + public PageResponse listFavorites(Long memberId, int page) { + Page favorites = + locationFavoriteRepository.findAllByMemberIdOrderByCreatedAtDescIdDesc( + memberId, + PageRequest.of(page, PAGE_SIZE)); + List favoriteIds = favorites.getContent().stream() + .map(LocationFavorite::getLocationId) + .toList(); + + return PageResponse.of( + locationRepository.findByIds(favoriteIds), + page, + PAGE_SIZE, + favorites.getTotalElements()); + } } diff --git a/location/src/main/java/com/nalssilog/location/domain/LocationErrorCode.java b/location/src/main/java/com/nalssilog/location/domain/LocationErrorCode.java index 283e5fc..c4f69c5 100644 --- a/location/src/main/java/com/nalssilog/location/domain/LocationErrorCode.java +++ b/location/src/main/java/com/nalssilog/location/domain/LocationErrorCode.java @@ -10,6 +10,10 @@ public enum LocationErrorCode implements ErrorCode { LOCATION_NOT_FOUND("LOCATION_NOT_FOUND", "지역을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + FAVORITE_ALREADY_EXISTS( + "FAVORITE_ALREADY_EXISTS", + "이미 즐겨찾기에 추가한 지역입니다.", + HttpStatus.CONFLICT), INVALID_COORDINATES("INVALID_COORDINATES", "위도 또는 경도가 올바르지 않습니다.", HttpStatus.BAD_REQUEST), KAKAO_MAP_API_UNAVAILABLE("KAKAO_MAP_API_UNAVAILABLE", "위치 정보를 불러오지 못했습니다.", HttpStatus.BAD_GATEWAY); diff --git a/location/src/test/java/com/nalssilog/location/application/LocationFavoriteServiceTest.java b/location/src/test/java/com/nalssilog/location/application/LocationFavoriteServiceTest.java index 878016d..08ad4f5 100644 --- a/location/src/test/java/com/nalssilog/location/application/LocationFavoriteServiceTest.java +++ b/location/src/test/java/com/nalssilog/location/application/LocationFavoriteServiceTest.java @@ -1,17 +1,25 @@ package com.nalssilog.location.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.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.common.response.PageResponse; import com.nalssilog.location.application.dto.LocationInfo; +import com.nalssilog.location.domain.LocationErrorCode; import com.nalssilog.location.domain.LocationFavorite; import com.nalssilog.location.repository.LocationFavoriteRepository; import com.nalssilog.location.repository.LocationRepository; +import java.sql.SQLException; import java.util.List; +import org.hibernate.exception.ConstraintViolationException; import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.PageImpl; import org.springframework.data.domain.PageRequest; @@ -25,6 +33,39 @@ class LocationFavoriteServiceTest { private final LocationFavoriteService service = new LocationFavoriteService(favoriteRepository, locationRepository); + @Test + void existingFavoriteIsIdempotent() { + when(favoriteRepository.existsByMemberIdAndLocationId(7L, 11L)) + .thenReturn(true); + + service.addFavorite(7L, 11L); + + verify(locationRepository).getById(11L); + verify(favoriteRepository, never()).saveAndFlush(any()); + } + + @Test + void concurrentFavoriteCollisionBecomesDomainConflict() { + DataIntegrityViolationException collision = new DataIntegrityViolationException( + "duplicate favorite", + new ConstraintViolationException( + "duplicate favorite", + new SQLException(), + "uk_location_favorite_member_location")); + + when(favoriteRepository.existsByMemberIdAndLocationId(7L, 11L)) + .thenReturn(false); + when(favoriteRepository.saveAndFlush(any(LocationFavorite.class))) + .thenThrow(collision); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.addFavorite(7L, 11L)); + + assertThat(exception.getErrorCode()) + .isEqualTo(LocationErrorCode.FAVORITE_ALREADY_EXISTS); + } + @Test void loadsFavoritesInFixedFiveItemPagesWithTotals() { List ids = List.of(11L, 12L); diff --git a/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java b/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java index 22dbc8d..e184024 100644 --- a/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java +++ b/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java @@ -1,7 +1,18 @@ package com.nalssilog.member.application; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +import org.hibernate.exception.ConstraintViolationException; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.application.dto.MemberSummary; import com.nalssilog.member.application.dto.SocialLoginResult; import com.nalssilog.member.domain.Member; import com.nalssilog.member.domain.MemberErrorCode; @@ -10,102 +21,146 @@ import com.nalssilog.member.domain.event.MemberWithdrawnEvent; import com.nalssilog.member.repository.MemberRepository; import com.nalssilog.member.repository.SocialAccountRepository; -import java.util.Optional; + import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class MemberAccountService { - private final MemberRepository memberRepository; - private final SocialAccountRepository socialAccountRepository; - private final ApplicationEventPublisher eventPublisher; - - /** 소셜 인증 결과 분기(생성·병합 안 함). 가입된 소셜=EXISTING, 이메일로 기존 회원 있으면 LINK_REQUIRED, 없으면 NEW. */ - public SocialLoginResult resolveSocialLogin(Provider provider, String providerUserId, String email) { - Optional linked = socialAccountRepository.findByProviderAndProviderUserId( - provider, - providerUserId); - - if (linked.isPresent()) { - SocialAccount account = linked.get(); - - return SocialLoginResult.existing(account.getMember().getId(), account.getMember().getStatus()); - } - - if (email == null || email.isBlank()) { - - return SocialLoginResult.newMember(email); - } - - return memberRepository.findMemberInfoByEmail(email) - .map(found -> SocialLoginResult.linkRequired(found.id(), email, found.connectedProviders())) - .orElseGet(() -> SocialLoginResult.newMember(email)); - } - - /** 기존 회원에 새 소셜 계정 연동(호출 전 재인증으로 소유권 증명 전제). */ - @Transactional - public MemberInfo linkSocial( - Long targetMemberId, - Provider provider, - String providerUserId, - String email - ) { - - if (socialAccountRepository.findByProviderAndProviderUserId(provider, providerUserId).isPresent()) { - throw new NalssiLogException(MemberErrorCode.SOCIAL_ACCOUNT_IN_USE); - } - - Member member = memberRepository.getMember(targetMemberId); - - socialAccountRepository.save(SocialAccount.link(member, provider, providerUserId, email)); - - return memberRepository.getMemberInfo(targetMemberId); - } - - /** 최종 로그인 세션 발급에 사용한 소셜 계정의 로그인 시각만 갱신한다. */ - @Transactional - public void recordLogin(Long memberId, Provider provider) { - SocialAccount account = socialAccountRepository - .findByMemberIdAndProvider(memberId, provider) - .orElseThrow(() -> new NalssiLogException( - MemberErrorCode.SOCIAL_ACCOUNT_NOT_FOUND)); - - account.touchLogin(); - } - - /** 회원 탈퇴. 익명화 + 소셜 삭제 + 제보 익명화용 MemberWithdrawnEvent 발행. 세션·쿠키 정리는 auth. */ - @Transactional - public void withdraw(Long memberId) { - Member member = memberRepository.getMember(memberId); - - member.withdraw(); - socialAccountRepository.deleteAllByMemberId(memberId); - - eventPublisher.publishEvent(MemberWithdrawnEvent.of(memberId)); - } - - public MemberInfo getMemberInfo(Long memberId) { - - return memberRepository.getMemberInfo(memberId); - } - - public Optional findMemberInfo(Long memberId) { - - return memberRepository.findMemberInfo(memberId); - } - - public Optional findMemberInfo( - Provider provider, - String providerUserId - ) { - - return socialAccountRepository - .findByProviderAndProviderUserId(provider, providerUserId) - .map(account -> memberRepository.getMemberInfo(account.getMember().getId())); - } + private static final String PROVIDER_USER_CONSTRAINT = + "uk_social_account_provider_user"; + private static final String MEMBER_PROVIDER_CONSTRAINT = + "uk_social_account_member_provider"; + + private final MemberRepository memberRepository; + private final SocialAccountRepository socialAccountRepository; + private final ApplicationEventPublisher eventPublisher; + + private static RuntimeException translateSocialLinkConflict( + DataIntegrityViolationException exception + ) { + String constraintName = constraintName(exception); + + if (PROVIDER_USER_CONSTRAINT.equalsIgnoreCase(constraintName)) { + return new NalssiLogException(MemberErrorCode.SOCIAL_ACCOUNT_IN_USE); + } + if (MEMBER_PROVIDER_CONSTRAINT.equalsIgnoreCase(constraintName)) { + return new NalssiLogException(MemberErrorCode.ACCOUNT_ALREADY_LINKED); + } + + return exception; + } + + private static String constraintName(Throwable throwable) { + for (Throwable cause = throwable; cause != null; cause = cause.getCause()) { + if (cause instanceof ConstraintViolationException constraintViolation) { + return constraintViolation.getConstraintName(); + } + } + + return null; + } + + /** 소셜 인증 결과 분기(생성·병합 안 함). 가입된 소셜=EXISTING, 이메일로 기존 회원 있으면 LINK_REQUIRED, 없으면 NEW. */ + public SocialLoginResult resolveSocialLogin(Provider provider, String providerUserId, String email) { + Optional linked = socialAccountRepository.findByProviderAndProviderUserId( + provider, + providerUserId); + + if (linked.isPresent()) { + SocialAccount account = linked.get(); + + return SocialLoginResult.existing(account.getMember().getId(), account.getMember().getStatus()); + } + + if (email == null || email.isBlank()) { + return SocialLoginResult.newMember(email); + } + + return memberRepository.findMemberInfoByEmail(email) + .map(found -> SocialLoginResult.linkRequired(found.id(), email, found.connectedProviders())) + .orElseGet(() -> SocialLoginResult.newMember(email)); + } + + /** 기존 회원에 새 소셜 계정 연동(호출 전 재인증으로 소유권 증명 전제). */ + @Transactional + public MemberInfo linkSocial( + Long targetMemberId, + Provider provider, + String providerUserId, + String email + ) { + Optional linkedAccount = + socialAccountRepository.findByProviderAndProviderUserId( + provider, + providerUserId); + + if (linkedAccount.isPresent()) { + MemberErrorCode errorCode = linkedAccount.get().getMember().getId().equals(targetMemberId) + ? MemberErrorCode.ACCOUNT_ALREADY_LINKED + : MemberErrorCode.SOCIAL_ACCOUNT_IN_USE; + + throw new NalssiLogException(errorCode); + } + + if (socialAccountRepository.findByMemberIdAndProvider(targetMemberId, provider).isPresent()) { + throw new NalssiLogException(MemberErrorCode.ACCOUNT_ALREADY_LINKED); + } + + Member member = memberRepository.getMember(targetMemberId); + + try { + socialAccountRepository.saveAndFlush( + SocialAccount.link(member, provider, providerUserId, email)); + } catch (DataIntegrityViolationException exception) { + throw translateSocialLinkConflict(exception); + } + + return memberRepository.getMemberInfo(targetMemberId); + } + + /** 최종 로그인 세션 발급에 사용한 소셜 계정의 로그인 시각만 갱신한다. */ + @Transactional + public void recordLogin(Long memberId, Provider provider) { + SocialAccount account = socialAccountRepository + .findByMemberIdAndProvider(memberId, provider) + .orElseThrow(() -> new NalssiLogException( + MemberErrorCode.SOCIAL_ACCOUNT_NOT_FOUND)); + + account.touchLogin(); + } + + /** 회원 탈퇴. 익명화 + 소셜 삭제 + 제보 익명화용 MemberWithdrawnEvent 발행. 세션·쿠키 정리는 auth. */ + @Transactional + public void withdraw(Long memberId) { + Member member = memberRepository.getMember(memberId); + + member.withdraw(); + socialAccountRepository.deleteAllByMemberId(memberId); + + eventPublisher.publishEvent(MemberWithdrawnEvent.of(memberId)); + } + + public MemberInfo getMemberInfo(Long memberId) { + return memberRepository.getMemberInfo(memberId); + } + + public Optional findMemberInfo(Long memberId) { + return memberRepository.findMemberInfo(memberId); + } + + public List findMemberSummaries(Collection memberIds) { + return memberRepository.findSummariesByIds(memberIds); + } + + public Optional findMemberInfo( + Provider provider, + String providerUserId + ) { + return socialAccountRepository + .findByProviderAndProviderUserId(provider, providerUserId) + .map(account -> memberRepository.getMemberInfo(account.getMember().getId())); + } } diff --git a/member/src/main/java/com/nalssilog/member/application/dto/MemberSummary.java b/member/src/main/java/com/nalssilog/member/application/dto/MemberSummary.java new file mode 100644 index 0000000..bc97df9 --- /dev/null +++ b/member/src/main/java/com/nalssilog/member/application/dto/MemberSummary.java @@ -0,0 +1,27 @@ +package com.nalssilog.member.application.dto; + +import com.nalssilog.member.domain.AvatarType; +import com.nalssilog.member.domain.Member; +import com.nalssilog.member.domain.MemberStatus; + +/** + * 다른 모듈의 목록 조회에서 사용하는 회원 표시 정보. + * 소셜 계정 컬렉션을 조립하지 않아 여러 회원을 한 번의 쿼리로 조회할 수 있다. + */ +public record MemberSummary( + Long id, + String nickname, + AvatarType avatarType, + String avatarValue, + MemberStatus status +) { + + public static MemberSummary of(Member member) { + return new MemberSummary( + member.getId(), + member.getNickname(), + member.getAvatarType(), + member.getAvatarValue(), + member.getStatus()); + } +} diff --git a/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java b/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java index f7f9c83..50c4617 100644 --- a/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java +++ b/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java @@ -22,7 +22,12 @@ @Entity @Table(name = "social_account", uniqueConstraints = { - @UniqueConstraint(name = "uk_social_account_provider_user", columnNames = {"provider", "provider_user_id"}) + @UniqueConstraint( + name = "uk_social_account_provider_user", + columnNames = {"provider", "provider_user_id"}), + @UniqueConstraint( + name = "uk_social_account_member_provider", + columnNames = {"member_id", "provider"}) }) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) @@ -66,7 +71,6 @@ public static SocialAccount link( String providerUserId, String providerEmail ) { - return create(member, provider, providerUserId, providerEmail); } diff --git a/member/src/main/java/com/nalssilog/member/repository/MemberRepository.java b/member/src/main/java/com/nalssilog/member/repository/MemberRepository.java index 107bcdd..7fe430f 100644 --- a/member/src/main/java/com/nalssilog/member/repository/MemberRepository.java +++ b/member/src/main/java/com/nalssilog/member/repository/MemberRepository.java @@ -2,10 +2,12 @@ import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.MemberInfo; +import com.nalssilog.member.application.dto.MemberSummary; import com.nalssilog.member.domain.Member; import com.nalssilog.member.domain.MemberErrorCode; import com.nalssilog.member.domain.MemberStatus; import com.nalssilog.member.domain.SocialAccount; +import java.util.Collection; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -41,6 +43,16 @@ public Optional findMemberInfoByEmail(String email) { .map(this::toInfo); } + public List findSummariesByIds(Collection memberIds) { + if (memberIds.isEmpty()) { + return List.of(); + } + + return memberJpaRepository.findAllById(memberIds).stream() + .map(MemberSummary::of) + .toList(); + } + public boolean existsByNickname(String nickname) { return memberJpaRepository.existsByNickname(nickname); } diff --git a/member/src/main/java/com/nalssilog/member/repository/SocialAccountJpaRepository.java b/member/src/main/java/com/nalssilog/member/repository/SocialAccountJpaRepository.java index 365fafc..790db42 100644 --- a/member/src/main/java/com/nalssilog/member/repository/SocialAccountJpaRepository.java +++ b/member/src/main/java/com/nalssilog/member/repository/SocialAccountJpaRepository.java @@ -11,8 +11,6 @@ */ public interface SocialAccountJpaRepository extends JpaRepository { - Optional findByProviderAndProviderUserId(Provider provider, String providerUserId); - Optional findByMemberIdAndProvider(Long memberId, Provider provider); List findAllByMemberId(Long memberId); diff --git a/member/src/main/java/com/nalssilog/member/repository/SocialAccountRepository.java b/member/src/main/java/com/nalssilog/member/repository/SocialAccountRepository.java index 302deef..9d60fdb 100644 --- a/member/src/main/java/com/nalssilog/member/repository/SocialAccountRepository.java +++ b/member/src/main/java/com/nalssilog/member/repository/SocialAccountRepository.java @@ -1,8 +1,12 @@ package com.nalssilog.member.repository; +import static com.nalssilog.member.domain.QMember.member; +import static com.nalssilog.member.domain.QSocialAccount.socialAccount; + import com.nalssilog.member.application.dto.SocialAccountInfo; import com.nalssilog.member.domain.Provider; import com.nalssilog.member.domain.SocialAccount; +import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import java.util.Optional; import lombok.RequiredArgsConstructor; @@ -17,6 +21,7 @@ public class SocialAccountRepository { private final SocialAccountJpaRepository socialAccountJpaRepository; + private final JPAQueryFactory queryFactory; // ===== 읽기 ===== @@ -33,7 +38,14 @@ public long countByMemberId(Long memberId) { // ===== 쓰기(관리 엔티티) ===== public Optional findByProviderAndProviderUserId(Provider provider, String providerUserId) { - return socialAccountJpaRepository.findByProviderAndProviderUserId(provider, providerUserId); + return Optional.ofNullable(queryFactory + .selectFrom(socialAccount) + .join(socialAccount.member, member) + .fetchJoin() + .where( + socialAccount.provider.eq(provider), + socialAccount.providerUserId.eq(providerUserId)) + .fetchOne()); } public Optional findByMemberIdAndProvider(Long memberId, Provider provider) { @@ -44,6 +56,10 @@ public SocialAccount save(SocialAccount socialAccount) { return socialAccountJpaRepository.save(socialAccount); } + public SocialAccount saveAndFlush(SocialAccount socialAccount) { + return socialAccountJpaRepository.saveAndFlush(socialAccount); + } + public void delete(SocialAccount socialAccount) { socialAccountJpaRepository.delete(socialAccount); } diff --git a/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java b/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java index ad9dfc6..c41cbbb 100644 --- a/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java +++ b/member/src/test/java/com/nalssilog/member/application/MemberAccountServiceTest.java @@ -1,21 +1,28 @@ package com.nalssilog.member.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.Mockito.mock; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.member.application.dto.SocialLoginResult; import com.nalssilog.member.domain.Member; +import com.nalssilog.member.domain.MemberErrorCode; import com.nalssilog.member.domain.MemberStatus; import com.nalssilog.member.domain.Provider; import com.nalssilog.member.domain.SocialAccount; import com.nalssilog.member.repository.MemberRepository; import com.nalssilog.member.repository.SocialAccountRepository; +import java.sql.SQLException; import java.util.Optional; +import org.hibernate.exception.ConstraintViolationException; import org.junit.jupiter.api.Test; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; @SuppressWarnings("java:S5960") class MemberAccountServiceTest { @@ -62,4 +69,64 @@ void finalServiceLoginRecordsSelectedSocialProvider() { verify(account).touchLogin(); } + + @Test + void rejectsLinkWhenMemberAlreadyHasProvider() { + when(socialAccountRepository.findByProviderAndProviderUserId( + Provider.KAKAO, + "new-kakao-user")).thenReturn(Optional.empty()); + when(socialAccountRepository.findByMemberIdAndProvider( + 7L, + Provider.KAKAO)).thenReturn(Optional.of(mock(SocialAccount.class))); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.linkSocial( + 7L, + Provider.KAKAO, + "new-kakao-user", + "user@example.com")); + + assertThat(exception.getErrorCode()) + .isEqualTo(MemberErrorCode.ACCOUNT_ALREADY_LINKED); + verify(socialAccountRepository, never()).saveAndFlush(any()); + } + + @Test + void translatesConcurrentProviderUserCollisionToDomainConflict() { + Member member = mock(Member.class); + + when(socialAccountRepository.findByProviderAndProviderUserId( + Provider.KAKAO, + "kakao-user")).thenReturn(Optional.empty()); + when(socialAccountRepository.findByMemberIdAndProvider( + 7L, + Provider.KAKAO)).thenReturn(Optional.empty()); + when(memberRepository.getMember(7L)).thenReturn(member); + when(socialAccountRepository.saveAndFlush(any(SocialAccount.class))) + .thenThrow(constraintCollision( + "uk_social_account_provider_user")); + + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + () -> service.linkSocial( + 7L, + Provider.KAKAO, + "kakao-user", + "user@example.com")); + + assertThat(exception.getErrorCode()) + .isEqualTo(MemberErrorCode.SOCIAL_ACCOUNT_IN_USE); + } + + private static DataIntegrityViolationException constraintCollision( + String constraintName + ) { + return new DataIntegrityViolationException( + "duplicate social account", + new ConstraintViolationException( + "duplicate social account", + new SQLException(), + constraintName)); + } } diff --git a/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java b/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java index 1a7915f..514fbc9 100644 --- a/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java +++ b/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java @@ -1,5 +1,15 @@ package com.nalssilog.report.application; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + import com.nalssilog.location.application.dto.PopularLocationSnapshotData; import com.nalssilog.location.config.PopularLocationProperties; import com.nalssilog.location.domain.PopularRankMovement; @@ -8,17 +18,10 @@ import com.nalssilog.report.domain.PopularLocationSnapshot; import com.nalssilog.report.repository.PopularLocationRankJpaRepository; import com.nalssilog.report.repository.PopularLocationSnapshotJpaRepository; +import com.nalssilog.report.repository.PopularLocationSnapshotLockRepository; import com.nalssilog.report.repository.WeatherReportRepository; -import java.time.Duration; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; + import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; /** * 최근 제보 집계를 순위 스냅샷으로 저장하고 최신 스냅샷을 제공한다. @@ -27,158 +30,193 @@ @RequiredArgsConstructor public class PopularLocationSnapshotService { - private static final String ALGORITHM_VERSION = "UNIQUE_REPORTERS_V1"; - - private final WeatherReportRepository reportRepository; - private final PopularLocationSnapshotJpaRepository snapshotRepository; - private final PopularLocationRankJpaRepository rankRepository; - private final PopularLocationProperties properties; - - @Transactional - public PopularLocationSnapshotData latestOrRefreshAt(Instant now) { - PopularLocationSnapshot latest = snapshotRepository - .findFirstByOrderByCalculatedAtDescIdDesc() - .orElse(null); - - if (latest != null - && !latest.getCalculatedAt().isBefore(now.minus(properties.snapshotInterval()))) { - - return snapshotData(latest); - } - - return createSnapshot(now, latest); - } - - @Transactional - public PopularLocationSnapshotData captureAt(Instant calculatedAt) { - PopularLocationSnapshot previous = snapshotRepository - .findFirstByOrderByCalculatedAtDescIdDesc() - .orElse(null); - - return createSnapshot(calculatedAt, previous); - } - - private PopularLocationSnapshotData createSnapshot( - Instant calculatedAt, - PopularLocationSnapshot previous - ) { - Instant snapshotTime = calculatedAt.truncatedTo(ChronoUnit.SECONDS); - Instant windowStartedAt = snapshotTime.minus(properties.window()); - List aggregates = - reportRepository.findPopularLocationAggregates( - windowStartedAt, - snapshotTime, - properties.limit()); - Map previousPositions = previousPositions(previous); - PopularLocationSnapshot snapshot = PopularLocationSnapshot.create( - snapshotTime, - windowStartedAt, - snapshotTime, - properties.limit(), - ALGORITHM_VERSION); - - snapshotRepository.save(snapshot); - - List ranks = new ArrayList<>(aggregates.size()); - - for (int index = 0; index < aggregates.size(); index++) { - PopularLocationAggregate aggregate = aggregates.get(index); - int position = index + 1; - Integer previousPosition = previousPositions.get(aggregate.locationId()); - Integer rankChange = previousPosition == null - ? null - : previousPosition - position; - - ranks.add(PopularLocationRank.create( - snapshot.getId(), - aggregate.locationId(), - position, - previousPosition, - rankChange, - movement(rankChange), - aggregate.uniqueReporterCount(), - aggregate.reportCount(), - aggregate.latestReportAt())); - } - - rankRepository.saveAll(ranks); - - return snapshotData(snapshot, ranks); - } - - private Map previousPositions(PopularLocationSnapshot previous) { - if (!isComparable(previous)) { - - return Map.of(); - } - - return rankRepository.findAllBySnapshotIdOrderByPositionAsc(previous.getId()).stream() - .collect(Collectors.toMap( - PopularLocationRank::getLocationId, - PopularLocationRank::getPosition)); - } - - private boolean isComparable(PopularLocationSnapshot previous) { - if (previous == null - || !ALGORITHM_VERSION.equals(previous.getAlgorithmVersion()) - || previous.getRankingLimit() != properties.limit()) { - - return false; - } - - Duration previousWindow = Duration.between( - previous.getWindowStartedAt(), - previous.getWindowEndedAt()); - - return previousWindow.equals(properties.window()); - } - - private PopularLocationSnapshotData snapshotData(PopularLocationSnapshot snapshot) { - List ranks = - rankRepository.findAllBySnapshotIdOrderByPositionAsc(snapshot.getId()); - - return snapshotData(snapshot, ranks); - } - - private PopularLocationSnapshotData snapshotData( - PopularLocationSnapshot snapshot, - List ranks - ) { - List rankings = ranks.stream() - .map(rank -> new PopularLocationSnapshotData.Rank( - rank.getLocationId(), - rank.getPosition(), - rank.getPreviousPosition(), - rank.getRankChange(), - rank.getMovement(), - rank.getUniqueReporterCount(), - rank.getReportCount(), - rank.getLatestReportAt())) - .toList(); - - return new PopularLocationSnapshotData( - snapshot.getId(), - snapshot.getCalculatedAt(), - snapshot.getWindowStartedAt(), - snapshot.getWindowEndedAt(), - snapshot.getAlgorithmVersion(), - rankings); - } - - private static PopularRankMovement movement(Integer rankChange) { - if (rankChange == null) { - - return PopularRankMovement.NEW; - } - if (rankChange > 0) { - - return PopularRankMovement.UP; - } - if (rankChange < 0) { - - return PopularRankMovement.DOWN; - } - - return PopularRankMovement.SAME; - } + private static final String ALGORITHM_VERSION = "UNIQUE_REPORTERS_V1"; + + private final WeatherReportRepository reportRepository; + private final PopularLocationSnapshotJpaRepository snapshotRepository; + private final PopularLocationRankJpaRepository rankRepository; + private final PopularLocationSnapshotLockRepository lockRepository; + private final PopularLocationProperties properties; + + private static PopularRankMovement movement(Integer rankChange) { + if (rankChange == null) { + return PopularRankMovement.NEW; + } + + if (rankChange > 0) { + return PopularRankMovement.UP; + } + + if (rankChange < 0) { + return PopularRankMovement.DOWN; + } + + return PopularRankMovement.SAME; + } + + @Transactional + public PopularLocationSnapshotData latestOrRefreshAt(Instant now) { + PopularLocationSnapshot latest = snapshotRepository + .findFirstByOrderByCalculatedAtDescIdDesc() + .orElse(null); + + if (isFresh(latest, now)) { + return snapshotData(latest); + } + + lockRepository.acquire(); + + latest = snapshotRepository + .findFirstByOrderByCalculatedAtDescIdDesc() + .orElse(null); + + if (isFresh(latest, now)) { + return snapshotData(latest); + } + + return createSnapshot(now, latest); + } + + @Transactional + public PopularLocationSnapshotData captureAt(Instant calculatedAt) { + lockRepository.acquire(); + + PopularLocationSnapshot previous = snapshotRepository + .findFirstByOrderByCalculatedAtDescIdDesc() + .orElse(null); + Instant snapshotTime = calculationBucketStart(calculatedAt); + + if (isSameCalculation(previous, snapshotTime)) { + return snapshotData(previous); + } + + return createSnapshot(snapshotTime, previous); + } + + private PopularLocationSnapshotData createSnapshot( + Instant calculatedAt, + PopularLocationSnapshot previous + ) { + Instant snapshotTime = calculationBucketStart(calculatedAt); + Instant windowStartedAt = snapshotTime.minus(properties.window()); + List aggregates = + reportRepository.findPopularLocationAggregates( + windowStartedAt, + snapshotTime, + properties.limit()); + Map previousPositions = previousPositions(previous); + PopularLocationSnapshot snapshot = PopularLocationSnapshot.create( + snapshotTime, + windowStartedAt, + snapshotTime, + properties.limit(), + ALGORITHM_VERSION); + + snapshotRepository.saveAndFlush(snapshot); + + List ranks = new ArrayList<>(aggregates.size()); + + for (int index = 0; index < aggregates.size(); index++) { + PopularLocationAggregate aggregate = aggregates.get(index); + int position = index + 1; + Integer previousPosition = previousPositions.get(aggregate.locationId()); + Integer rankChange = previousPosition == null + ? null + : previousPosition - position; + + ranks.add(PopularLocationRank.create( + snapshot.getId(), + aggregate.locationId(), + position, + previousPosition, + rankChange, + movement(rankChange), + aggregate.uniqueReporterCount(), + aggregate.reportCount(), + aggregate.latestReportAt())); + } + + rankRepository.saveAll(ranks); + + return snapshotData(snapshot, ranks); + } + + private Map previousPositions(PopularLocationSnapshot previous) { + if (!isComparable(previous)) { + return Map.of(); + } + + return rankRepository.findAllBySnapshotIdOrderByPositionAsc(previous.getId()).stream() + .collect(Collectors.toMap( + PopularLocationRank::getLocationId, + PopularLocationRank::getPosition)); + } + + private boolean isComparable(PopularLocationSnapshot previous) { + if (previous == null + || !ALGORITHM_VERSION.equals(previous.getAlgorithmVersion()) + || previous.getRankingLimit() != properties.limit()) { + return false; + } + + Duration previousWindow = Duration.between( + previous.getWindowStartedAt(), + previous.getWindowEndedAt()); + + return previousWindow.equals(properties.window()); + } + + private boolean isFresh( + PopularLocationSnapshot snapshot, + Instant now + ) { + return isComparable(snapshot) + && !snapshot.getCalculatedAt() + .isBefore(now.minus(properties.snapshotInterval())); + } + + private boolean isSameCalculation( + PopularLocationSnapshot snapshot, + Instant calculatedAt + ) { + return isComparable(snapshot) && snapshot.getCalculatedAt().equals(calculatedAt); + } + + private Instant calculationBucketStart(Instant calculatedAt) { + long intervalMillis = Math.max(1L, properties.snapshotInterval().toMillis()); + long bucketMillis = Math.floorDiv(calculatedAt.toEpochMilli(), intervalMillis) * intervalMillis; + + return Instant.ofEpochMilli(bucketMillis); + } + + private PopularLocationSnapshotData snapshotData(PopularLocationSnapshot snapshot) { + List ranks = rankRepository.findAllBySnapshotIdOrderByPositionAsc(snapshot.getId()); + + return snapshotData(snapshot, ranks); + } + + private PopularLocationSnapshotData snapshotData( + PopularLocationSnapshot snapshot, + List ranks + ) { + List rankings = ranks.stream() + .map(rank -> new PopularLocationSnapshotData.Rank( + rank.getLocationId(), + rank.getPosition(), + rank.getPreviousPosition(), + rank.getRankChange(), + rank.getMovement(), + rank.getUniqueReporterCount(), + rank.getReportCount(), + rank.getLatestReportAt())) + .toList(); + + return new PopularLocationSnapshotData( + snapshot.getId(), + snapshot.getCalculatedAt(), + snapshot.getWindowStartedAt(), + snapshot.getWindowEndedAt(), + snapshot.getAlgorithmVersion(), + rankings); + } } diff --git a/report/src/main/java/com/nalssilog/report/application/ReportService.java b/report/src/main/java/com/nalssilog/report/application/ReportService.java index 5f887b8..efd2769 100644 --- a/report/src/main/java/com/nalssilog/report/application/ReportService.java +++ b/report/src/main/java/com/nalssilog/report/application/ReportService.java @@ -83,9 +83,15 @@ public CursorPage list(Long locationId, String cursor, ReportAct List reportIds = page.stream().map(ReportData::id).toList(); Map counts = thanksRepository.countByReportIds(reportIds); Set thanked = thanksRepository.thankedReportIds(reportIds, viewer); + Map authors = memberClient.findActiveAuthors( + page.stream() + .filter(data -> data.authorType() == ActorType.MEMBER) + .map(ReportData::authorMemberId) + .distinct() + .toList()); List items = page.stream() - .map(data -> ReportResponse.of(data, location, resolveAuthor(data), + .map(data -> ReportResponse.of(data, location, authors.get(data.authorMemberId()), counts.getOrDefault(data.id(), 0L), thanked.contains(data.id()), isAuthor(data, ownershipActors), diff --git a/report/src/main/java/com/nalssilog/report/client/MemberClient.java b/report/src/main/java/com/nalssilog/report/client/MemberClient.java index e684a69..8e40b54 100644 --- a/report/src/main/java/com/nalssilog/report/client/MemberClient.java +++ b/report/src/main/java/com/nalssilog/report/client/MemberClient.java @@ -1,9 +1,15 @@ package com.nalssilog.report.client; import com.nalssilog.member.application.MemberAccountService; +import com.nalssilog.member.application.dto.MemberSummary; import com.nalssilog.member.domain.MemberStatus; import com.nalssilog.report.application.dto.AuthorInfo; +import java.util.Collection; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; @@ -18,9 +24,24 @@ public class MemberClient { private final MemberAccountService memberAccountService; public Optional findActiveAuthor(Long memberId) { - return memberAccountService.findMemberInfo(memberId) + return Optional.ofNullable( + findActiveAuthors(List.of(memberId)).get(memberId)); + } + + public Map findActiveAuthors(Collection memberIds) { + return memberAccountService.findMemberSummaries(memberIds).stream() .filter(member -> member.status() == MemberStatus.ACTIVE) - .map(member -> new AuthorInfo( - member.id(), member.nickname(), member.avatarType(), member.avatarValue())); + .map(MemberClient::toAuthorInfo) + .collect(Collectors.toUnmodifiableMap( + AuthorInfo::id, + Function.identity())); + } + + private static AuthorInfo toAuthorInfo(MemberSummary member) { + return new AuthorInfo( + member.id(), + member.nickname(), + member.avatarType(), + member.avatarValue()); } } diff --git a/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshot.java b/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshot.java index ce0e82f..69cc1d8 100644 --- a/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshot.java +++ b/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshot.java @@ -7,6 +7,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Index; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.time.Instant; import lombok.AccessLevel; import lombok.Getter; @@ -19,7 +20,15 @@ @Table(name = "popular_location_snapshot", indexes = @Index( name = "idx_popular_location_snapshot_calculated", - columnList = "calculated_at")) + columnList = "calculated_at"), + uniqueConstraints = @UniqueConstraint( + name = "uk_popular_location_snapshot_calculation", + columnNames = { + "calculated_at", + "window_started_at", + "ranking_limit", + "algorithm_version" + })) @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) public class PopularLocationSnapshot { diff --git a/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshotLock.java b/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshotLock.java new file mode 100644 index 0000000..ec457f3 --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/domain/PopularLocationSnapshotLock.java @@ -0,0 +1,23 @@ +package com.nalssilog.report.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 여러 애플리케이션 인스턴스의 인기 지역 스냅샷 갱신을 직렬화하는 DB 잠금 행. + */ +@Entity +@Table(name = "popular_location_snapshot_lock") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PopularLocationSnapshotLock { + + @Id + @Column(name = "id", nullable = false) + private Long id; +} diff --git a/report/src/main/java/com/nalssilog/report/domain/ReportErrorCode.java b/report/src/main/java/com/nalssilog/report/domain/ReportErrorCode.java index fcf8112..20991c3 100644 --- a/report/src/main/java/com/nalssilog/report/domain/ReportErrorCode.java +++ b/report/src/main/java/com/nalssilog/report/domain/ReportErrorCode.java @@ -12,6 +12,10 @@ public enum ReportErrorCode implements ErrorCode { REPORT_NOT_FOUND("REPORT_NOT_FOUND", "제보를 찾을 수 없습니다.", HttpStatus.NOT_FOUND), REPORT_DELETE_FORBIDDEN("REPORT_DELETE_FORBIDDEN", "본인이 작성한 제보만 삭제할 수 있습니다.", HttpStatus.FORBIDDEN), + POPULAR_SNAPSHOT_LOCK_UNAVAILABLE( + "POPULAR_SNAPSHOT_LOCK_UNAVAILABLE", + "인기 지역 순위를 갱신할 수 없습니다.", + HttpStatus.SERVICE_UNAVAILABLE), INVALID_CURSOR("INVALID_CURSOR", "잘못된 커서입니다.", HttpStatus.BAD_REQUEST), UNSUPPORTED_IMAGE_TYPE("UNSUPPORTED_IMAGE_TYPE", "지원하지 않는 이미지 형식입니다.", HttpStatus.BAD_REQUEST), IMAGE_TOO_LARGE("IMAGE_TOO_LARGE", "이미지 한 장은 5MB 이하여야 합니다.", HttpStatus.BAD_REQUEST), diff --git a/report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepository.java b/report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepository.java new file mode 100644 index 0000000..4f08e1e --- /dev/null +++ b/report/src/main/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepository.java @@ -0,0 +1,30 @@ +package com.nalssilog.report.repository; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.domain.PopularLocationSnapshotLock; +import com.nalssilog.report.domain.ReportErrorCode; +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class PopularLocationSnapshotLockRepository { + + private static final Long LOCK_ID = 1L; + + private final EntityManager entityManager; + + public void acquire() { + PopularLocationSnapshotLock lock = entityManager.find( + PopularLocationSnapshotLock.class, + LOCK_ID, + LockModeType.PESSIMISTIC_WRITE); + + if (lock == null) { + throw new NalssiLogException( + ReportErrorCode.POPULAR_SNAPSHOT_LOCK_UNAVAILABLE); + } + } +} diff --git a/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java b/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java index f4b26f6..c204560 100644 --- a/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java +++ b/report/src/main/java/com/nalssilog/report/repository/WeatherReportRepository.java @@ -1,6 +1,7 @@ package com.nalssilog.report.repository; import static com.nalssilog.report.domain.QWeatherReport.weatherReport; +import static com.nalssilog.report.domain.QWeatherReportImage.weatherReportImage; import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.report.application.dto.PopularLocationAggregate; @@ -21,8 +22,11 @@ import jakarta.persistence.EntityManager; import java.time.Instant; import java.util.EnumMap; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -41,7 +45,6 @@ public class WeatherReportRepository { private final EntityManager entityManager; public ReportData save(WeatherReport report) { - return ReportData.of(weatherReportJpaRepository.save(report)); } @@ -68,15 +71,24 @@ public int anonymizeAuthor(Long memberId) { } public ReportData getReport(Long reportId) { - return ReportData.of(getReportEntity(reportId)); } /** 삭제처럼 관리 엔티티가 필요한 쓰기 유스케이스 전용. 반드시 트랜잭션 안에서 사용한다. */ public WeatherReport getReportEntity(Long reportId) { + WeatherReport report = queryFactory + .selectFrom(weatherReport) + .distinct() + .leftJoin(weatherReport.images, weatherReportImage) + .fetchJoin() + .where(weatherReport.id.eq(reportId)) + .fetchOne(); + + if (report == null) { + throw new NalssiLogException(ReportErrorCode.REPORT_NOT_FOUND); + } - return weatherReportJpaRepository.findById(reportId) - .orElseThrow(() -> new NalssiLogException(ReportErrorCode.REPORT_NOT_FOUND)); + return report; } public void delete(WeatherReport report) { @@ -89,7 +101,7 @@ public List findPage(Long locationId, Instant cursorTime, Long curso ? weatherReportJpaRepository.findAllByLocationIdOrderByCreatedAtDescIdDesc(locationId, pageable) : findAfterLocationCursor(locationId, cursorTime, cursorId, limit); - return reports.stream() + return fetchImages(reports).stream() .map(ReportData::of) .toList(); } @@ -101,7 +113,7 @@ public List findMemberPage(Long memberId, Instant cursorTime, Long c ActorType.MEMBER, memberId, pageable) : findAfterMemberCursor(memberId, cursorTime, cursorId, limit); - return reports.stream() + return fetchImages(reports).stream() .map(ReportData::of) .toList(); } @@ -158,7 +170,6 @@ public WeatherStatsData statsSince(Long locationId, Instant since) { private List findAfterLocationCursor( Long locationId, Instant cursorTime, Long cursorId, int limit) { - return queryFactory .selectFrom(weatherReport) .where( @@ -172,7 +183,6 @@ private List findAfterLocationCursor( private List findAfterMemberCursor( Long memberId, Instant cursorTime, Long cursorId, int limit) { - return queryFactory .selectFrom(weatherReport) .where( @@ -185,8 +195,38 @@ private List findAfterMemberCursor( .fetch(); } - private BooleanExpression beforeCursor(Instant cursorTime, Long cursorId) { + /** + * 컬렉션 fetch join에 페이지 제한을 직접 적용하면 메모리 페이징이 발생할 수 있다. + * 먼저 루트 엔티티를 제한한 뒤 선택된 ID만 이미지와 fetch join하고 기존 순서를 복원한다. + */ + private List fetchImages(List reports) { + if (reports.isEmpty()) { + return List.of(); + } + + List reportIds = reports.stream() + .map(WeatherReport::getId) + .toList(); + Map fetchedById = queryFactory + .selectFrom(weatherReport) + .distinct() + .leftJoin(weatherReport.images, weatherReportImage) + .fetchJoin() + .where(weatherReport.id.in(reportIds)) + .fetch() + .stream() + .collect(Collectors.toMap( + WeatherReport::getId, + Function.identity(), + (first, ignored) -> first, + LinkedHashMap::new)); + + return reportIds.stream() + .map(fetchedById::get) + .toList(); + } + private BooleanExpression beforeCursor(Instant cursorTime, Long cursorId) { return weatherReport.createdAt.lt(cursorTime) .or(weatherReport.createdAt.eq(cursorTime).and(weatherReport.id.lt(cursorId))); } @@ -212,7 +252,6 @@ private > Map countByAxis( } private static long value(Long value) { - return value == null ? 0 : value; } } diff --git a/report/src/test/java/com/nalssilog/report/application/PopularLocationSnapshotServiceTest.java b/report/src/test/java/com/nalssilog/report/application/PopularLocationSnapshotServiceTest.java index 1437846..f86d0d2 100644 --- a/report/src/test/java/com/nalssilog/report/application/PopularLocationSnapshotServiceTest.java +++ b/report/src/test/java/com/nalssilog/report/application/PopularLocationSnapshotServiceTest.java @@ -15,6 +15,7 @@ import com.nalssilog.report.domain.PopularLocationSnapshot; import com.nalssilog.report.repository.PopularLocationRankJpaRepository; import com.nalssilog.report.repository.PopularLocationSnapshotJpaRepository; +import com.nalssilog.report.repository.PopularLocationSnapshotLockRepository; import com.nalssilog.report.repository.WeatherReportRepository; import java.time.Duration; import java.time.Instant; @@ -38,11 +39,14 @@ class PopularLocationSnapshotServiceTest { mock(PopularLocationSnapshotJpaRepository.class); private final PopularLocationRankJpaRepository rankRepository = mock(PopularLocationRankJpaRepository.class); + private final PopularLocationSnapshotLockRepository lockRepository = + mock(PopularLocationSnapshotLockRepository.class); private final PopularLocationSnapshotService service = new PopularLocationSnapshotService( reportRepository, snapshotRepository, rankRepository, + lockRepository, PROPERTIES); @Test @@ -141,7 +145,55 @@ void reusesFreshSnapshotWithoutRunningAggregation() { .satisfies(ranking -> assertThat(ranking.locationId()).isEqualTo(11L)); verify(reportRepository, never()) .findPopularLocationAggregates(any(), any(), anyInt()); - verify(snapshotRepository, never()).save(any()); + verify(snapshotRepository, never()).saveAndFlush(any()); + verify(lockRepository, never()).acquire(); + } + + @Test + void rechecksLatestSnapshotAfterAcquiringDistributedLock() { + Instant now = Instant.parse("2026-07-30T06:05:00Z"); + PopularLocationSnapshot concurrentSnapshot = snapshot( + 100L, + Instant.parse("2026-07-30T06:00:00Z")); + PopularLocationRank storedRank = rank(100L, 11L, 1); + + when(snapshotRepository.findFirstByOrderByCalculatedAtDescIdDesc()) + .thenReturn( + Optional.empty(), + Optional.of(concurrentSnapshot)); + when(rankRepository.findAllBySnapshotIdOrderByPositionAsc(100L)) + .thenReturn(List.of(storedRank)); + + var result = service.latestOrRefreshAt(now); + + assertThat(result.snapshotId()).isEqualTo(100L); + verify(lockRepository).acquire(); + verify(reportRepository, never()) + .findPopularLocationAggregates(any(), any(), anyInt()); + } + + @Test + void reusesSnapshotFromSameCalculationBucket() { + Instant requestedAt = Instant.parse("2026-07-30T06:07:00Z"); + PopularLocationSnapshot existing = snapshot( + 100L, + Instant.parse("2026-07-30T06:00:00Z")); + PopularLocationRank storedRank = rank(100L, 11L, 1); + + when(snapshotRepository.findFirstByOrderByCalculatedAtDescIdDesc()) + .thenReturn(Optional.of(existing)); + when(rankRepository.findAllBySnapshotIdOrderByPositionAsc(100L)) + .thenReturn(List.of(storedRank)); + + var result = service.captureAt(requestedAt); + + assertThat(result.snapshotId()).isEqualTo(100L); + assertThat(result.calculatedAt()) + .isEqualTo(Instant.parse("2026-07-30T06:00:00Z")); + verify(lockRepository).acquire(); + verify(reportRepository, never()) + .findPopularLocationAggregates(any(), any(), anyInt()); + verify(snapshotRepository, never()).saveAndFlush(any()); } @Test @@ -173,7 +225,7 @@ void startsNewMovementBaselineWhenAlgorithmChanges() { } private void assignSnapshotId(Long id) { - when(snapshotRepository.save(any(PopularLocationSnapshot.class))) + when(snapshotRepository.saveAndFlush(any(PopularLocationSnapshot.class))) .thenAnswer(invocation -> { PopularLocationSnapshot snapshot = invocation.getArgument(0); @@ -205,7 +257,6 @@ private static PopularLocationSnapshot snapshot( } private static PopularLocationRank rank(Long snapshotId, Long locationId, int position) { - return PopularLocationRank.create( snapshotId, locationId, @@ -219,7 +270,6 @@ private static PopularLocationRank rank(Long snapshotId, Long locationId, int po } private static PopularLocationAggregate aggregate(Long locationId, Instant calculatedAt) { - return new PopularLocationAggregate( locationId, 1, diff --git a/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java b/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java index 453fc3a..5e48cf3 100644 --- a/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java +++ b/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java @@ -13,6 +13,7 @@ import com.nalssilog.common.exception.NalssiLogException; import com.nalssilog.common.response.CursorPage; import com.nalssilog.report.api.dto.ReportResponse; +import com.nalssilog.report.application.dto.AuthorInfo; import com.nalssilog.report.application.dto.LocationSummary; import com.nalssilog.report.application.dto.ReportActor; import com.nalssilog.report.application.dto.ReportData; @@ -145,6 +146,34 @@ void listIncludesOwnershipCalculatedFromAllAvailableActors() { .isEqualTo(true); } + @Test + void listLoadsDistinctMemberAuthorsInOneBulkCall() { + ReportData first = memberData(10L, 7L); + ReportData second = memberData(11L, 8L); + AuthorInfo firstAuthor = new AuthorInfo(7L, "first", null, null); + AuthorInfo secondAuthor = new AuthorInfo(8L, "second", null, null); + + when(reportRepository.findPage(eq(1L), isNull(), isNull(), eq(21))) + .thenReturn(List.of(first, second)); + when(locationClient.getLocation(1L)).thenReturn(location()); + when(thanksRepository.countByReportIds(List.of(10L, 11L))).thenReturn(Map.of()); + when(thanksRepository.thankedReportIds(List.of(10L, 11L), null)).thenReturn(Set.of()); + when(memberClient.findActiveAuthors(List.of(7L, 8L))) + .thenReturn(Map.of(7L, firstAuthor, 8L, secondAuthor)); + + CursorPage response = service.list( + 1L, + null, + null, + List.of()); + + assertThat(response.items()) + .extracting(item -> item.author().id()) + .containsExactly("7", "8"); + verify(memberClient).findActiveAuthors(List.of(7L, 8L)); + verify(memberClient, never()).findActiveAuthor(any()); + } + @Test void statsAggregatesReportsFromTheLastThreeHours() { WeatherStatsData stats = new WeatherStatsData(0L, Map.of(), Map.of(), Map.of()); @@ -187,6 +216,22 @@ private ReportData anonymousData(String anonymousKey) { ); } + private ReportData memberData(Long reportId, Long memberId) { + return new ReportData( + reportId, + 1L, + ActorType.MEMBER, + memberId, + null, + Temperature.FRESH, + Precipitation.NONE, + Sunlight.MODERATE, + "맑아요", + List.of(), + Instant.parse("2026-07-24T00:00:00Z") + ); + } + private LocationSummary location() { return new LocationSummary( 1L, "서울특별시", "강남구", "역삼동", "서울특별시 강남구 역삼동", "강남구 역삼동"); diff --git a/report/src/test/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepositoryTest.java b/report/src/test/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepositoryTest.java new file mode 100644 index 0000000..8b8a1e6 --- /dev/null +++ b/report/src/test/java/com/nalssilog/report/repository/PopularLocationSnapshotLockRepositoryTest.java @@ -0,0 +1,50 @@ +package com.nalssilog.report.repository; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.catchThrowableOfType; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.nalssilog.common.exception.NalssiLogException; +import com.nalssilog.report.domain.PopularLocationSnapshotLock; +import com.nalssilog.report.domain.ReportErrorCode; +import jakarta.persistence.EntityManager; +import jakarta.persistence.LockModeType; +import org.junit.jupiter.api.Test; + +@SuppressWarnings("java:S5960") +class PopularLocationSnapshotLockRepositoryTest { + + private final EntityManager entityManager = mock(EntityManager.class); + private final PopularLocationSnapshotLockRepository repository = + new PopularLocationSnapshotLockRepository(entityManager); + + @Test + void acquiresDatabasePessimisticWriteLock() { + PopularLocationSnapshotLock lock = + mock(PopularLocationSnapshotLock.class); + + when(entityManager.find( + PopularLocationSnapshotLock.class, + 1L, + LockModeType.PESSIMISTIC_WRITE)).thenReturn(lock); + + repository.acquire(); + + verify(entityManager).find( + PopularLocationSnapshotLock.class, + 1L, + LockModeType.PESSIMISTIC_WRITE); + } + + @Test + void failsWithDomainErrorWhenLockRowIsMissing() { + NalssiLogException exception = catchThrowableOfType( + NalssiLogException.class, + repository::acquire); + + assertThat(exception.getErrorCode()) + .isEqualTo(ReportErrorCode.POPULAR_SNAPSHOT_LOCK_UNAVAILABLE); + } +} From e60408a38e7d1bef560f19fd237d322bc505f58f Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Fri, 31 Jul 2026 14:20:01 +0900 Subject: [PATCH 6/7] =?UTF-8?q?chore:=20=EB=B0=B1=EC=97=94=EB=93=9C=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EB=B2=84=EC=A0=84=EC=9D=84=200.2.0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20-=20gradle.properties?= =?UTF-8?q?=EB=A5=BC=20=EC=95=A0=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=B2=84=EC=A0=84=EC=9D=98=20=EB=8B=A8=EC=9D=BC=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=EC=A0=90=EC=9C=BC=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20-=20=EB=AA=A8=EB=93=A0=20Gradle=20=EB=AA=A8?= =?UTF-8?q?=EB=93=88=EA=B3=BC=20=EC=8B=A4=ED=96=89=20JAR=EC=97=90=200.2.0?= =?UTF-8?q?=20=EB=B2=84=EC=A0=84=20=EC=A0=81=EC=9A=A9=20-=20=EC=9E=AC?= =?UTF-8?q?=ED=98=84=20=EA=B0=80=EB=8A=A5=ED=95=9C=20Spring=20build-info?= =?UTF-8?q?=EB=A5=BC=20=EC=83=9D=EC=84=B1=ED=95=98=EA=B3=A0=20health=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=EC=97=90=20=EB=B2=84=EC=A0=84=20=EB=85=B8?= =?UTF-8?q?=EC=B6=9C=20-=20Docker=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EC=97=90?= =?UTF-8?q?=20=EC=95=A0=ED=94=8C=EB=A6=AC=EC=BC=80=EC=9D=B4=EC=85=98=20?= =?UTF-8?q?=EB=B2=84=EC=A0=84=EA=B3=BC=20Git=20revision=20OCI=20label=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20-=20dev=EC=99=80=20prod=20=EC=9D=B4?= =?UTF-8?q?=EB=AF=B8=EC=A7=80=EC=97=90=20=EB=B2=84=EC=A0=84=20=EB=B0=8F=20?= =?UTF-8?q?=EC=BB=A4=EB=B0=8B=20SHA=20=EA=B8=B0=EB=B0=98=20=EB=B6=88?= =?UTF-8?q?=EB=B3=80=20=ED=83=9C=EA=B7=B8=20=EB=B0=9C=EA=B8=89=20-=20?= =?UTF-8?q?=EC=9A=B4=EC=98=81=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EC=97=90=200.2?= =?UTF-8?q?.0=20=EB=B2=84=EC=A0=84=20=EB=B3=84=EC=B9=AD=EA=B3=BC=20prod=20?= =?UTF-8?q?=EC=B5=9C=EC=8B=A0=20=EB=B3=84=EC=B9=AD=20=EC=9C=A0=EC=A7=80=20?= =?UTF-8?q?-=20Compose=EA=B0=80=20APP=5FIMAGE=5FTAG=EB=A5=BC=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=ED=95=B4=20=EC=A0=95=ED=99=95=ED=95=9C=20=EB=B9=8C?= =?UTF-8?q?=EB=93=9C=20=EC=9D=B4=EB=AF=B8=EC=A7=80=EB=A5=BC=20=EB=B0=B0?= =?UTF-8?q?=ED=8F=AC=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20-?= =?UTF-8?q?=20health=20=EB=B2=84=EC=A0=84=20=EC=9D=91=EB=8B=B5=20=ED=9A=8C?= =?UTF-8?q?=EA=B7=80=20=ED=85=8C=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 --- .github/workflows/cd-dev.yml | 30 +++++++++++++---- .github/workflows/cd-prod.yml | 33 +++++++++++++++---- Dockerfile | 8 +++++ app/build.gradle | 6 ++++ .../nalssilog/app/api/HealthController.java | 9 +++-- .../app/api/HealthControllerTest.java | 25 ++++++++++++++ build.gradle | 19 +++++++++-- deploy/compose-dev.yml | 4 +-- deploy/compose-prod.yml | 4 +-- gradle.properties | 1 + 10 files changed, 117 insertions(+), 22 deletions(-) create mode 100644 app/src/test/java/com/nalssilog/app/api/HealthControllerTest.java create mode 100644 gradle.properties diff --git a/.github/workflows/cd-dev.yml b/.github/workflows/cd-dev.yml index 75c2688..18fb516 100644 --- a/.github/workflows/cd-dev.yml +++ b/.github/workflows/cd-dev.yml @@ -62,6 +62,14 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Resolve application version + run: | + chmod +x gradlew + APP_VERSION=$(./gradlew -q printVersion) + SHORT_SHA=${GITHUB_SHA::12} + echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_ENV" + echo "APP_IMAGE_TAG=${APP_VERSION}-dev-${SHORT_SHA}" >> "$GITHUB_ENV" + - name: Build and test direct push if: ${{ steps.push-source.outputs.direct_push == 'true' }} run: | @@ -83,13 +91,19 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build & push image (arm64, :dev) + - name: Build & push versioned image (arm64) uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . platforms: linux/arm64 push: true - tags: ghcr.io/nalssilog/api:dev + tags: | + ghcr.io/nalssilog/api:dev + ghcr.io/nalssilog/api:${{ env.APP_IMAGE_TAG }} + ghcr.io/nalssilog/api:sha-${{ github.sha }} + build-args: | + APP_VERSION=${{ env.APP_VERSION }} + VCS_REF=${{ github.sha }} - name: Open SSH (add runner IP to SG) env: @@ -124,6 +138,8 @@ jobs: - name: Write .env.dev & deploy on EC2 uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5 env: + APP_VERSION: ${{ env.APP_VERSION }} + APP_IMAGE_TAG: ${{ env.APP_IMAGE_TAG }} GHCR_USERNAME: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} DB_HOST: ${{ secrets.DEV_DB_HOST }} @@ -148,11 +164,11 @@ jobs: host: ${{ secrets.EC2_HOST_DEV }} username: ${{ secrets.EC2_USER_DEV }} key: ${{ secrets.EC2_SSH_KEY_DEV }} - envs: GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY_BASE64,DEV_R2_PUBLIC_BASE_URL,DEV_R2_ENDPOINT,DEV_R2_BUCKET,DEV_R2_ACCESS_KEY,DEV_R2_SECRET_KEY + envs: APP_VERSION,APP_IMAGE_TAG,GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY_BASE64,DEV_R2_PUBLIC_BASE_URL,DEV_R2_ENDPOINT,DEV_R2_BUCKET,DEV_R2_ACCESS_KEY,DEV_R2_SECRET_KEY script: | set -eu cd /home/ubuntu/nalssilog-dev-api - for name in GHCR_USERNAME GHCR_TOKEN DB_HOST DB_PORT DB_NAME DB_USERNAME DB_PASSWORD DEV_R2_PUBLIC_BASE_URL DEV_R2_ENDPOINT DEV_R2_BUCKET DEV_R2_ACCESS_KEY DEV_R2_SECRET_KEY; do + for name in APP_VERSION APP_IMAGE_TAG GHCR_USERNAME GHCR_TOKEN DB_HOST DB_PORT DB_NAME DB_USERNAME DB_PASSWORD DEV_R2_PUBLIC_BASE_URL DEV_R2_ENDPOINT DEV_R2_BUCKET DEV_R2_ACCESS_KEY DEV_R2_SECRET_KEY; do if [ -z "$(printenv "$name")" ]; then echo "Missing required deployment value: $name" exit 1 @@ -175,6 +191,8 @@ jobs: export SPRING_PROFILES_ACTIVE=$ACTIVE_PROFILES umask 077 cat > .env.dev </dev/null 2>&1 || true' EXIT - docker compose -f compose-dev.yml pull - docker compose -f compose-dev.yml up -d + docker compose --env-file .env.dev -f compose-dev.yml pull + docker compose --env-file .env.dev -f compose-dev.yml up -d healthy=false for attempt in $(seq 1 30); do if curl --fail --silent --max-time 3 http://127.0.0.1:8080/api/health >/dev/null; then diff --git a/.github/workflows/cd-prod.yml b/.github/workflows/cd-prod.yml index a121724..50d8d30 100644 --- a/.github/workflows/cd-prod.yml +++ b/.github/workflows/cd-prod.yml @@ -1,6 +1,6 @@ name: CD (prod) -# main PR CI 통과 후 merge → prod 이미지(ghcr :prod) → prod EC2 배포. +# main PR CI 통과 후 merge → 버전·커밋 태그 이미지 생성 → 해당 불변 태그로 prod EC2 배포. on: push: branches: [main] @@ -53,6 +53,14 @@ jobs: - name: Set up Gradle uses: gradle/actions/setup-gradle@3f131e8634966bd73d06cc69884922b02e6faf92 # v6.2.0 + - name: Resolve application version + run: | + chmod +x gradlew + APP_VERSION=$(./gradlew -q printVersion) + SHORT_SHA=${GITHUB_SHA::12} + echo "APP_VERSION=$APP_VERSION" >> "$GITHUB_ENV" + echo "APP_IMAGE_TAG=${APP_VERSION}-${SHORT_SHA}" >> "$GITHUB_ENV" + - name: Build bootJar run: | chmod +x gradlew @@ -68,13 +76,20 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build & push image (arm64, :prod) + - name: Build & push versioned image (arm64) uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . platforms: linux/arm64 push: true - tags: ghcr.io/nalssilog/api:prod + tags: | + ghcr.io/nalssilog/api:prod + ghcr.io/nalssilog/api:${{ env.APP_VERSION }} + ghcr.io/nalssilog/api:${{ env.APP_IMAGE_TAG }} + ghcr.io/nalssilog/api:sha-${{ github.sha }} + build-args: | + APP_VERSION=${{ env.APP_VERSION }} + VCS_REF=${{ github.sha }} # GitHub 러너 IP 는 매번 바뀌므로 SSH 전에 SG 22번에 추가, 끝나면 제거(항상). (aws CLI 는 러너에 기본 설치) - name: Open SSH (add runner IP to SG) @@ -110,6 +125,8 @@ jobs: - name: Write .env.prod & deploy on EC2 uses: appleboy/ssh-action@0ff4204d59e8e51228ff73bce53f80d53301dee2 # v1.2.5 env: + APP_VERSION: ${{ env.APP_VERSION }} + APP_IMAGE_TAG: ${{ env.APP_IMAGE_TAG }} GHCR_USERNAME: ${{ github.actor }} GHCR_TOKEN: ${{ secrets.GITHUB_TOKEN }} DB_HOST: ${{ secrets.PROD_DB_HOST }} @@ -137,11 +154,11 @@ jobs: host: ${{ secrets.EC2_HOST_PROD }} username: ${{ secrets.EC2_USER_PROD }} key: ${{ secrets.EC2_SSH_KEY_PROD }} - envs: GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY_BASE64,PROD_R2_PUBLIC_BASE_URL,PROD_R2_ENDPOINT,PROD_R2_BUCKET,PROD_R2_ACCESS_KEY,PROD_R2_SECRET_KEY + envs: APP_VERSION,APP_IMAGE_TAG,GHCR_USERNAME,GHCR_TOKEN,DB_HOST,DB_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,OAUTH_GOOGLE_CLIENT_ID,OAUTH_GOOGLE_CLIENT_SECRET,OAUTH_KAKAO_CLIENT_ID,OAUTH_KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_TEAM_ID,APPLE_KEY_ID,APPLE_PRIVATE_KEY_BASE64,PROD_R2_PUBLIC_BASE_URL,PROD_R2_ENDPOINT,PROD_R2_BUCKET,PROD_R2_ACCESS_KEY,PROD_R2_SECRET_KEY script: | set -eu cd /home/ubuntu/nalssilog-prod-api - for name in GHCR_USERNAME GHCR_TOKEN DB_HOST DB_PORT DB_NAME DB_USERNAME DB_PASSWORD REDIS_HOST REDIS_PORT REDIS_PASSWORD PROD_R2_PUBLIC_BASE_URL PROD_R2_ENDPOINT PROD_R2_BUCKET PROD_R2_ACCESS_KEY PROD_R2_SECRET_KEY; do + for name in APP_VERSION APP_IMAGE_TAG GHCR_USERNAME GHCR_TOKEN DB_HOST DB_PORT DB_NAME DB_USERNAME DB_PASSWORD REDIS_HOST REDIS_PORT REDIS_PASSWORD PROD_R2_PUBLIC_BASE_URL PROD_R2_ENDPOINT PROD_R2_BUCKET PROD_R2_ACCESS_KEY PROD_R2_SECRET_KEY; do if [ -z "$(printenv "$name")" ]; then echo "Missing required deployment value: $name" exit 1 @@ -164,6 +181,8 @@ jobs: export SPRING_PROFILES_ACTIVE=$ACTIVE_PROFILES umask 077 cat > .env.prod </dev/null 2>&1 || true' EXIT - docker compose -f compose-prod.yml pull - docker compose -f compose-prod.yml up -d + docker compose --env-file .env.prod -f compose-prod.yml pull + docker compose --env-file .env.prod -f compose-prod.yml up -d healthy=false for attempt in $(seq 1 30); do if curl --fail --silent --max-time 3 http://127.0.0.1:8081/api/health >/dev/null; then diff --git a/Dockerfile b/Dockerfile index 058937c..b234c15 100644 --- a/Dockerfile +++ b/Dockerfile @@ -2,6 +2,14 @@ # JAR 은 아키텍처 무관(JVM 바이트코드)이고 base 는 multi-arch 라, buildx --platform linux/arm64 로 t4g(ARM) 이미지 생성 가능. FROM eclipse-temurin:25-jre +ARG APP_VERSION=development +ARG VCS_REF=unknown + +LABEL org.opencontainers.image.title="nalssilog-api" \ + org.opencontainers.image.version="${APP_VERSION}" \ + org.opencontainers.image.revision="${VCS_REF}" \ + org.opencontainers.image.source="https://github.com/nalssilog/api" + WORKDIR /app # 애플리케이션은 특권이 필요 없으므로 고정 UID/GID의 비-root 사용자로 실행한다. diff --git a/app/build.gradle b/app/build.gradle index 7d78bbd..c0d98f6 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -2,6 +2,12 @@ plugins { id 'org.springframework.boot' } +springBoot { + buildInfo { + excludes = ['time'] + } +} + dependencies { implementation project(':common') implementation project(':auth') diff --git a/app/src/main/java/com/nalssilog/app/api/HealthController.java b/app/src/main/java/com/nalssilog/app/api/HealthController.java index cc2a9bd..f5344ee 100644 --- a/app/src/main/java/com/nalssilog/app/api/HealthController.java +++ b/app/src/main/java/com/nalssilog/app/api/HealthController.java @@ -1,5 +1,7 @@ package com.nalssilog.app.api; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.info.BuildProperties; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -7,13 +9,16 @@ * 로드밸런서·모니터링용 라이브니스 체크. 의존 상태 점검이 필요하면 Actuator 도입을 고려한다. */ @RestController +@RequiredArgsConstructor public class HealthController { + private final BuildProperties buildProperties; + @GetMapping("/api/health") public HealthResponse health() { - return new HealthResponse("UP"); + return new HealthResponse("UP", buildProperties.getVersion()); } - public record HealthResponse(String status) { + public record HealthResponse(String status, String version) { } } diff --git a/app/src/test/java/com/nalssilog/app/api/HealthControllerTest.java b/app/src/test/java/com/nalssilog/app/api/HealthControllerTest.java new file mode 100644 index 0000000..4c31afa --- /dev/null +++ b/app/src/test/java/com/nalssilog/app/api/HealthControllerTest.java @@ -0,0 +1,25 @@ +package com.nalssilog.app.api; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Properties; +import org.junit.jupiter.api.Test; +import org.springframework.boot.info.BuildProperties; + +@SuppressWarnings("java:S5960") +class HealthControllerTest { + + @Test + void exposesBuildVersionWithHealthStatus() { + String expectedVersion = "test-version"; + Properties properties = new Properties(); + properties.setProperty("version", expectedVersion); + HealthController controller = + new HealthController(new BuildProperties(properties)); + + HealthController.HealthResponse response = controller.health(); + + assertThat(response.status()).isEqualTo("UP"); + assertThat(response.version()).isEqualTo(expectedVersion); + } +} diff --git a/build.gradle b/build.gradle index cd02e26..886822c 100644 --- a/build.gradle +++ b/build.gradle @@ -8,13 +8,26 @@ ext { querydslVersion = '7.4.0' } +def applicationVersion = providers.gradleProperty('appVersion').get() + +allprojects { + group = 'com.nalssilog' + version = applicationVersion +} + +tasks.register('printVersion') { + group = 'help' + description = 'Prints the application release version.' + + doLast { + println project.version + } +} + subprojects { apply plugin: 'java-library' apply plugin: 'io.spring.dependency-management' - group = 'com.nalssilog' - version = '0.0.1-SNAPSHOT' - java { toolchain { languageVersion = JavaLanguageVersion.of(25) diff --git a/deploy/compose-dev.yml b/deploy/compose-dev.yml index 248c455..87ab37a 100644 --- a/deploy/compose-dev.yml +++ b/deploy/compose-dev.yml @@ -1,11 +1,11 @@ # dev 환경 compose. 지금은 prod 와 같은 EC2 에서 별도 프로젝트로 실행, 추후 인스턴스 분리 시 이 파일만 옮기면 됨. -# 이미지=ghcr(태그 :dev), Postgres=Neon(dev), Redis=아래 redis-dev 컨테이너, 이미지=R2. +# 이미지=ghcr(배포 시 버전·커밋 태그, 수동 실행 시 :dev), Postgres=Neon(dev), Redis=아래 redis-dev 컨테이너, 이미지=R2. # 설정: ./config 의 yml 마운트 + ./.env.dev 시크릿. HTTPS/라우팅은 호스트 nginx(EC2 에서 직접). name: nalssilog-dev services: app-dev: - image: ghcr.io/nalssilog/api:dev + image: ghcr.io/nalssilog/api:${APP_IMAGE_TAG:-dev} container_name: nalssilog-app-dev restart: unless-stopped environment: diff --git a/deploy/compose-prod.yml b/deploy/compose-prod.yml index 617231e..bcd2aaa 100644 --- a/deploy/compose-prod.yml +++ b/deploy/compose-prod.yml @@ -1,11 +1,11 @@ # prod 환경 compose. 지금은 dev 와 같은 EC2 에서 별도 프로젝트로 실행, 추후 인스턴스 분리 시 이 파일만 옮기면 됨. -# 이미지=ghcr(태그 :prod), Postgres=Neon(prod), Redis=Upstash(외부, .env.prod 의 PROD_REDIS_*), 이미지=R2. +# 이미지=ghcr(배포 시 버전·커밋 태그, 수동 실행 시 :prod), Postgres=Neon(prod), Redis=Upstash(외부, .env.prod 의 PROD_REDIS_*), 이미지=R2. # 설정: ./config 의 yml 마운트 + ./.env.prod 시크릿. HTTPS/라우팅은 호스트 nginx(EC2 에서 직접). name: nalssilog-prod services: app-prod: - image: ghcr.io/nalssilog/api:prod + image: ghcr.io/nalssilog/api:${APP_IMAGE_TAG:-prod} container_name: nalssilog-app-prod restart: unless-stopped environment: diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..9e6eb6d --- /dev/null +++ b/gradle.properties @@ -0,0 +1 @@ +appVersion=0.2.0 From a01dc69ab1c4fc45e87d251fb7b4b49c9af4b62f Mon Sep 17 00:00:00 2001 From: fakerdeft Date: Fri, 31 Jul 2026 15:11:16 +0900 Subject: [PATCH 7/7] =?UTF-8?q?refactor:=20=EC=8B=A0=EB=A2=B0=20=ED=94=84?= =?UTF-8?q?=EB=A1=9D=EC=8B=9C=20=EC=84=A4=EC=A0=95=EA=B3=BC=20Java=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20-=20=EC=9D=B8=EC=A6=9D=EA=B3=BC=20=ED=94=BC?= =?UTF-8?q?=EB=93=9C=EB=B0=B1=20IP=20=ED=8C=90=EB=B3=84=EC=9D=98=20?= =?UTF-8?q?=ED=95=98=EB=93=9C=EC=BD=94=EB=94=A9=EB=90=9C=20=ED=94=84?= =?UTF-8?q?=EB=A1=9D=EC=8B=9C=20=EB=8C=80=EC=97=AD=20=EC=A0=9C=EA=B1=B0=20?= =?UTF-8?q?-=20=ED=94=84=EB=A1=9D=EC=8B=9C=20=EB=AF=B8=EC=84=A4=EC=A0=95?= =?UTF-8?q?=20=EC=8B=9C=20=EB=B9=88=20=EB=AA=A9=EB=A1=9D=EC=9C=BC=EB=A1=9C?= =?UTF-8?q?=20=EC=B2=98=EB=A6=AC=ED=95=B4=20=EC=A0=84=EB=8B=AC=20=ED=97=A4?= =?UTF-8?q?=EB=8D=94=EB=A5=BC=20=EC=8B=A0=EB=A2=B0=ED=95=98=EC=A7=80=20?= =?UTF-8?q?=EC=95=8A=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD=20-=20JWT=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D,=20RT=20=EC=9E=AC=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=ED=83=90=EC=A7=80,=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20OAuth?= =?UTF-8?q?=EC=99=80=20CSRF=20=EC=98=A4=EB=A5=98=20=EB=B6=84=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EC=96=BC=EB=A6=AC=20=EB=A6=AC=ED=84=B4=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=A0=95=EB=A6=AC=20-=20=EC=97=B0=EC=86=8D=20?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4=EB=AC=B8=EA=B3=BC=20=EB=B3=80=EC=88=98/?= =?UTF-8?q?=EC=8B=A4=ED=96=89=20=EB=A1=9C=EC=A7=81=20=EC=82=AC=EC=9D=B4?= =?UTF-8?q?=EC=9D=98=20=EA=B3=B5=EB=B0=B1=20=EA=B7=9C=EC=B9=99=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20-=20=EB=8B=A8=EC=9D=BC=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=EB=AC=B8=EA=B3=BC=20=EC=B5=9C=EC=A2=85=20=EB=B0=98=ED=99=98?= =?UTF-8?q?=EB=AC=B8=EC=9D=98=20=EA=B3=B5=EB=B0=B1=20=EA=B7=9C=EC=B9=99?= =?UTF-8?q?=EC=9D=84=20=EC=A0=84=EC=B2=B4=20Java=20=EC=86=8C=EC=8A=A4?= =?UTF-8?q?=EC=97=90=20=EC=A0=81=EC=9A=A9=20-=20=ED=83=AD=20=EB=93=A4?= =?UTF-8?q?=EC=97=AC=EC=93=B0=EA=B8=B0=EB=A5=BC=204=EC=B9=B8=20=EA=B3=B5?= =?UTF-8?q?=EB=B0=B1=EC=9C=BC=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20-=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9D=98=20=ED=95=98=EB=93=9C?= =?UTF-8?q?=EC=BD=94=EB=94=A9=EB=90=9C=20IP=20=EB=A6=AC=ED=84=B0=EB=9F=B4?= =?UTF-8?q?=20=EC=A0=9C=EA=B1=B0=20-=20=EC=8B=A0=EB=A2=B0=20=ED=94=84?= =?UTF-8?q?=EB=A1=9D=EC=8B=9C=20=EC=84=A4=EC=A0=95=EC=9D=B4=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=EB=90=9C=20config=20=EC=84=9C=EB=B8=8C=EB=AA=A8?= =?UTF-8?q?=EB=93=88=20=EB=B2=84=EC=A0=84=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../app/api/GlobalExceptionHandler.java | 3 - .../app/api/GlobalExceptionHandlerTest.java | 1 - .../app/api/HealthControllerTest.java | 1 + .../nalssilog/auth/config/AuthProperties.java | 22 +- .../com/nalssilog/auth/core/AuthService.java | 21 +- .../auth/device/DeviceInfoResolver.java | 24 +- .../nalssilog/auth/member/MemberClient.java | 6 - .../auth/mobile/MobileAuthController.java | 5 - .../auth/mobile/MobileAuthService.java | 5 - .../auth/mobile/MobileRefreshResponse.java | 1 - .../auth/mobile/MobileTokenResponse.java | 1 - .../mobile/guest/GuestCredentialResponse.java | 1 - .../mobile/guest/MobileGuestController.java | 1 - .../mobile/guest/MobileGuestCredential.java | 2 - .../guest/MobileGuestCredentialFilter.java | 1 + .../guest/MobileGuestCredentialService.java | 1 + .../guest/MobileGuestIssuanceRateLimiter.java | 2 + ...leOAuthAuthorizationRequestRepository.java | 1 - ...bileOAuthAuthorizationRequestResolver.java | 8 +- .../mobile/oauth/MobileOAuthCodeStore.java | 8 +- .../auth/mobile/oauth/MobileOAuthGrant.java | 5 - .../auth/mobile/oauth/MobileOAuthService.java | 37 +- .../oauth/MobileOAuthTransactionStore.java | 8 +- .../auth/oauth/OAuth2LoginFailureHandler.java | 1 - .../auth/oauth/OAuth2LoginSuccessHandler.java | 4 + .../nalssilog/auth/oauth/OAuthUserInfo.java | 3 - .../auth/oauth/SocialAuthPrincipal.java | 4 - .../auth/oauth/SocialOidcPrincipal.java | 6 - .../nalssilog/auth/oauth/WebOAuthService.java | 12 - .../AppleAuthorizationResponseFilter.java | 6 +- .../apple/AppleAuthorizationUserContext.java | 2 +- .../apple/AppleClientSecretGenerator.java | 6 +- .../oauth/apple/AppleOAuthProperties.java | 6 +- .../security/ApiAuthenticationEntryPoint.java | 2 + .../auth/security/AuthSecurityConfig.java | 38 +- .../auth/ticket/AuthTicketStore.java | 13 - .../com/nalssilog/auth/ticket/LinkTicket.java | 1 - .../nalssilog/auth/ticket/SignupTicket.java | 1 - .../auth/token/AuthSessionService.java | 1 - .../auth/token/AuthTokenService.java | 28 +- .../auth/token/JwtAuthenticationFilter.java | 14 +- .../auth/token/JwtTokenProvider.java | 20 +- .../auth/token/RefreshTokenStore.java | 18 +- .../nalssilog/auth/web/AuthController.java | 2 - .../nalssilog/auth/web/AuthCookieManager.java | 6 - .../com/nalssilog/auth/web/MeResponse.java | 4 - .../nalssilog/auth/web/SessionResponse.java | 1 - .../auth/core/AuthServiceSignupTest.java | 6 +- ...bileGuestCredentialCleanupServiceTest.java | 1 - .../MobileGuestCredentialServiceTest.java | 5 +- .../MobileGuestIssuanceRateLimiterTest.java | 11 +- .../mobile/oauth/MobileOAuthServiceTest.java | 9 +- .../auth/oauth/WebOAuthServiceTest.java | 3 +- .../auth/token/AuthTokenServiceTest.java | 15 +- .../auth/token/JwtTokenProviderTest.java | 1 - .../auth/token/RefreshTokenStoreTest.java | 2 +- .../auth/token/TestAuthProperties.java | 2 - .../auth/web/AuthControllerTest.java | 2 - .../common/filter/RequestLoggingFilter.java | 3 - .../common/filter/TraceIdFilter.java | 56 +-- .../common/response/PageResponse.java | 2 - .../security/VerifiedRequestCredentials.java | 3 - .../common/web/TrustedProxyChain.java | 12 +- config | 2 +- .../location/api/LocationController.java | 4 - .../api/LocationFavoriteController.java | 1 - .../api/dto/PopularLocationsResponse.java | 1 - .../application/LocationFavoriteService.java | 122 +++--- .../location/application/LocationService.java | 3 - .../location/client/KakaoMapClient.java | 2 + .../location/config/KakaoMapProperties.java | 2 + .../location/config/LegalDongCsvReader.java | 8 + .../config/LegalDongDataInitializer.java | 1 + .../config/PopularLocationProperties.java | 3 + .../nalssilog/location/domain/Location.java | 1 + .../location/domain/LocationFavorite.java | 1 + .../repository/LocationRepository.java | 7 - .../application/LocationServiceTest.java | 1 - .../location/client/KakaoMapClientTest.java | 1 + .../member/api/MemberController.java | 8 - .../application/FeedbackRateLimiter.java | 1 - .../application/MemberAccountService.java | 269 ++++++------- .../application/MemberProfileService.java | 1 + .../MemberRegistrationService.java | 1 + .../config/FeedbackRateLimitProperties.java | 11 +- .../config/TrustedProxyClientIpResolver.java | 1 - .../com/nalssilog/member/domain/Feedback.java | 1 + .../com/nalssilog/member/domain/Member.java | 2 + .../member/domain/MemberConsent.java | 1 + .../com/nalssilog/member/domain/Provider.java | 1 - .../member/domain/SocialAccount.java | 2 + .../application/ConsentServiceTest.java | 1 + .../application/FeedbackRateLimiterTest.java | 18 +- .../application/FeedbackServiceTest.java | 6 +- .../application/MemberProfileServiceTest.java | 3 + .../MemberRegistrationServiceTest.java | 5 + .../TrustedProxyClientIpResolverTest.java | 43 +- .../report/api/dto/ReportResponse.java | 1 + .../report/api/dto/WeatherStatsResponse.java | 1 + .../PopularLocationSnapshotService.java | 378 +++++++++--------- .../ReportPopularLocationSource.java | 1 - .../report/application/ReportService.java | 2 + .../report/config/ReportActorResolver.java | 5 +- .../com/nalssilog/report/domain/Thanks.java | 1 + .../report/domain/WeatherReport.java | 3 + .../report/domain/WeatherReportImage.java | 1 + .../ReportImageCleanupListenerTest.java | 1 + .../report/application/ReportServiceTest.java | 8 + .../com/nalssilog/storage/StorageConfig.java | 1 - .../nalssilog/storage/StorageProperties.java | 1 - 110 files changed, 675 insertions(+), 761 deletions(-) diff --git a/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java b/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java index 561d9b7..4eac030 100644 --- a/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java +++ b/app/src/main/java/com/nalssilog/app/api/GlobalExceptionHandler.java @@ -64,7 +64,6 @@ public ErrorResponse handleValidationException(MethodArgumentNotValidException e MethodArgumentTypeMismatchException.class }) public ErrorResponse handleRequestParameterValidation(Exception exception) { - return new ErrorResponse( "VALIDATION_ERROR", "요청 파라미터가 올바르지 않습니다."); @@ -73,14 +72,12 @@ public ErrorResponse handleRequestParameterValidation(Exception exception) { @ResponseStatus(HttpStatus.BAD_REQUEST) @ExceptionHandler(HttpMessageNotReadableException.class) public ErrorResponse handleNotReadable(HttpMessageNotReadableException exception) { - return new ErrorResponse("INVALID_REQUEST", "요청 본문을 해석할 수 없습니다."); } @ResponseStatus(HttpStatus.NOT_FOUND) @ExceptionHandler(NoResourceFoundException.class) public ErrorResponse handleNoResourceFoundException(NoResourceFoundException exception) { - return new ErrorResponse("NOT_FOUND", "요청한 리소스를 찾을 수 없습니다."); } diff --git a/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java b/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java index b98d94b..893c55a 100644 --- a/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java +++ b/app/src/test/java/com/nalssilog/app/api/GlobalExceptionHandlerTest.java @@ -55,7 +55,6 @@ void refreshRejectionDeletesBothAuthenticationCookies() { } private AuthProperties properties() { - return new AuthProperties( new AuthProperties.Jwt( "test-secret-must-be-at-least-thirty-two-bytes", diff --git a/app/src/test/java/com/nalssilog/app/api/HealthControllerTest.java b/app/src/test/java/com/nalssilog/app/api/HealthControllerTest.java index 4c31afa..790b35e 100644 --- a/app/src/test/java/com/nalssilog/app/api/HealthControllerTest.java +++ b/app/src/test/java/com/nalssilog/app/api/HealthControllerTest.java @@ -13,6 +13,7 @@ class HealthControllerTest { void exposesBuildVersionWithHealthStatus() { String expectedVersion = "test-version"; Properties properties = new Properties(); + properties.setProperty("version", expectedVersion); HealthController controller = new HealthController(new BuildProperties(properties)); 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 9523367..464a664 100644 --- a/auth/src/main/java/com/nalssilog/auth/config/AuthProperties.java +++ b/auth/src/main/java/com/nalssilog/auth/config/AuthProperties.java @@ -30,10 +30,13 @@ public record AuthProperties( if (mobile == null) { mobile = Mobile.defaults(ipHmacFallback); - } else if (mobile.ipHmacSecret() == null + } + + if (mobile.ipHmacSecret() == null || mobile.ipHmacSecret().isBlank()) { mobile = mobile.withIpHmacSecret(ipHmacFallback); } + if (guest == null) { guest = Guest.defaults(); } @@ -79,19 +82,21 @@ public record Mobile( redirectUris = redirectUris == null || redirectUris.isEmpty() ? List.of() : List.copyOf(redirectUris); + if (transactionTtl == null || transactionTtl.isNegative() || transactionTtl.isZero()) { transactionTtl = Duration.ofMinutes(10); } + if (codeTtl == null || codeTtl.isNegative() || codeTtl.isZero()) { codeTtl = Duration.ofSeconds(90); } - trustedProxies = trustedProxies == null || trustedProxies.isEmpty() - ? List.of("127.0.0.0/8", "::1/128", "172.16.0.0/12") + + trustedProxies = trustedProxies == null + ? List.of() : List.copyOf(trustedProxies); } private Mobile withIpHmacSecret(String secret) { - return new Mobile( redirectUris, transactionTtl, @@ -101,7 +106,6 @@ private Mobile withIpHmacSecret(String secret) { } private static Mobile defaults(String ipHmacSecret) { - return new Mobile(null, null, null, ipHmacSecret, null); } } @@ -121,26 +125,33 @@ public record Guest( if (ttl == null || ttl.isNegative() || ttl.isZero()) { ttl = Duration.ofDays(365); } + if (maxIssuances <= 0) { maxIssuances = 300; } + if (rateLimitWindow == null || rateLimitWindow.isNegative() || rateLimitWindow.isZero()) { rateLimitWindow = Duration.ofMinutes(10); } + if (globalMaxIssuances <= 0) { globalMaxIssuances = 3_000; } + if (globalRateLimitWindow == null || globalRateLimitWindow.isNegative() || globalRateLimitWindow.isZero()) { globalRateLimitWindow = Duration.ofMinutes(1); } + if (expiredRetention == null || expiredRetention.isNegative()) { expiredRetention = Duration.ofDays(7); } + if (cleanupInterval == null || cleanupInterval.isNegative() || cleanupInterval.isZero()) { cleanupInterval = Duration.ofHours(6); } + if (cleanupInitialDelay == null || cleanupInitialDelay.isNegative() || cleanupInitialDelay.isZero()) { @@ -157,7 +168,6 @@ public Guest( } private static Guest defaults() { - return new Guest(null, 0, null, 0, null, null, null, null); } } diff --git a/auth/src/main/java/com/nalssilog/auth/core/AuthService.java b/auth/src/main/java/com/nalssilog/auth/core/AuthService.java index 202f485..f1fa1a5 100644 --- a/auth/src/main/java/com/nalssilog/auth/core/AuthService.java +++ b/auth/src/main/java/com/nalssilog/auth/core/AuthService.java @@ -48,7 +48,6 @@ public MeState me( boolean hasAuthenticationCookie ) { if (memberId != null) { - return MeState.authenticated(memberClient.getMemberInfo(memberId)); } @@ -76,7 +75,6 @@ public MeState me( } public SignupResult signup(String ticketId, List agreedTerms, DeviceInfo device) { - return signup(ticketId, agreedTerms, device, AuthChannel.WEB); } @@ -85,7 +83,6 @@ public SignupResult signupMobile( List agreedTerms, DeviceInfo device ) { - return signup(ticketId, agreedTerms, device, AuthChannel.MOBILE); } @@ -106,12 +103,13 @@ private SignupResult signup( properties.ticket().ttl()); if (claim.status() == SignupClaimStatus.COMPLETED) { - return replayCompletedSignup(claim.completion()); } + if (claim.status() == SignupClaimStatus.IN_PROGRESS) { throw new NalssiLogException(AuthErrorCode.AUTH_FLOW_IN_PROGRESS); } + if (claim.status() == SignupClaimStatus.MISSING) { throw new NalssiLogException(AuthErrorCode.TICKET_NOT_FOUND); } @@ -148,12 +146,10 @@ private SignupResult signup( } public TokenPair refresh(String refreshToken, DeviceInfo device) { - return refresh(refreshToken, device, true); } public TokenPair refreshMobile(String refreshToken, DeviceInfo device) { - return refresh(refreshToken, device, false); } @@ -188,7 +184,6 @@ public void withdraw(Long memberId) { } public List sessions(Long memberId, String currentSessionId) { - return authSessionService.listSessions(memberId, currentSessionId); } @@ -197,7 +192,6 @@ public boolean revokeSession( String sessionId, String currentSessionId ) { - return authSessionService.revokeSession(memberId, sessionId, currentSessionId); } @@ -237,7 +231,6 @@ public void cancelLink(Optional ticketId) { } public String oauthAuthorizationUrl(String provider) { - return "/oauth2/authorization/" + provider(provider).name().toLowerCase(Locale.ROOT); } @@ -255,7 +248,6 @@ private MemberInfo registerOrRecover( List agreedTerms ) { try { - return memberClient.registerMember( new OAuthUserInfo( ticket.provider(), @@ -264,7 +256,6 @@ private MemberInfo registerOrRecover( ticket.socialName()), agreedTerms); } catch (DataIntegrityViolationException exception) { - return memberClient.findMemberInfo( ticket.provider(), ticket.providerUserId()) @@ -303,14 +294,12 @@ private LinkTicket findLinkTicket(String ticketId) { } private boolean isTerminalRefreshError(NalssiLogException exception) { - return exception.getErrorCode() == AuthErrorCode.AUTH_SESSION_EXPIRED || exception.getErrorCode() == AuthErrorCode.AUTH_REFRESH_REUSED; } private Provider provider(String provider) { try { - return Provider.from(provider); } catch (IllegalArgumentException _) { throw new NalssiLogException(AuthErrorCode.UNSUPPORTED_PROVIDER); @@ -318,12 +307,10 @@ private Provider provider(String provider) { } private String loginUrl(Provider provider) { - return "/api/auth/login/" + provider.name().toLowerCase(Locale.ROOT); } private String reauthenticationUrl(Provider provider) { - return "/api/auth/link/reauth/" + provider.name().toLowerCase(Locale.ROOT); } @@ -343,12 +330,10 @@ public record MeState( ) { public static MeState authenticated(MemberInfo member) { - return new MeState(MeStatus.AUTHENTICATED, member, null, null, List.of()); } public static MeState signupRequired(Provider provider, String email) { - return new MeState(MeStatus.SIGNUP_REQUIRED, null, provider, email, List.of()); } @@ -357,12 +342,10 @@ public static MeState linkRequired( String email, List existingProviders ) { - return new MeState(MeStatus.LINK_REQUIRED, null, provider, email, List.copyOf(existingProviders)); } public static MeState none() { - return new MeState(MeStatus.NONE, null, null, null, List.of()); } } diff --git a/auth/src/main/java/com/nalssilog/auth/device/DeviceInfoResolver.java b/auth/src/main/java/com/nalssilog/auth/device/DeviceInfoResolver.java index 47d3ff9..b2fec85 100644 --- a/auth/src/main/java/com/nalssilog/auth/device/DeviceInfoResolver.java +++ b/auth/src/main/java/com/nalssilog/auth/device/DeviceInfoResolver.java @@ -32,7 +32,6 @@ public String resolveIp(HttpServletRequest request) { String ip = trustedProxyChain.resolve(request); if (!StringUtils.hasText(ip)) { - return ""; } @@ -41,7 +40,6 @@ public String resolveIp(HttpServletRequest request) { private String deviceName(String userAgent) { if (!StringUtils.hasText(userAgent)) { - return "알 수 없는 기기"; } @@ -54,27 +52,26 @@ private String deviceName(String userAgent) { private String browser(String ua) { if (ua.contains("Edg")) { - return "Edge"; } - if (ua.contains("SamsungBrowser")) { + if (ua.contains("SamsungBrowser")) { return "Samsung Internet"; } - if (ua.contains("OPR") || ua.contains("Opera")) { + if (ua.contains("OPR") || ua.contains("Opera")) { return "Opera"; } - if (ua.contains("Firefox")) { + if (ua.contains("Firefox")) { return "Firefox"; } - if (ua.contains("Chrome")) { + if (ua.contains("Chrome")) { return "Chrome"; } - if (ua.contains("Safari")) { + if (ua.contains("Safari")) { return "Safari"; } @@ -83,27 +80,26 @@ private String browser(String ua) { private String os(String ua) { if (ua.contains("iPhone")) { - return "iPhone"; } - if (ua.contains("iPad")) { + if (ua.contains("iPad")) { return "iPad"; } - if (ua.contains("Android")) { + if (ua.contains("Android")) { return "Android"; } - if (ua.contains("Windows")) { + if (ua.contains("Windows")) { return "Windows"; } - if (ua.contains("Mac OS X") || ua.contains("Macintosh")) { + if (ua.contains("Mac OS X") || ua.contains("Macintosh")) { return "Mac"; } - if (ua.contains("Linux")) { + if (ua.contains("Linux")) { return "Linux"; } diff --git a/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java b/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java index 242d647..d884f21 100644 --- a/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java +++ b/auth/src/main/java/com/nalssilog/auth/member/MemberClient.java @@ -23,25 +23,21 @@ public class MemberClient { private final MemberRegistrationService memberRegistrationService; public SocialLoginResult resolveSocialLogin(OAuthUserInfo userInfo) { - return memberAccountService.resolveSocialLogin( userInfo.provider(), userInfo.providerUserId(), userInfo.email()); } public MemberInfo registerMember(OAuthUserInfo userInfo, List agreedTerms) { - return memberRegistrationService.registerMember( userInfo.provider(), userInfo.providerUserId(), userInfo.email(), userInfo.socialName(), agreedTerms); } public MemberInfo linkSocial(Long targetMemberId, OAuthUserInfo userInfo) { - return memberAccountService.linkSocial( targetMemberId, userInfo.provider(), userInfo.providerUserId(), userInfo.email()); } public MemberInfo getMemberInfo(Long memberId) { - return memberAccountService.getMemberInfo(memberId); } @@ -50,7 +46,6 @@ public void recordLogin(Long memberId, Provider provider) { } public Optional findMemberInfo(Long memberId) { - return memberAccountService.findMemberInfo(memberId); } @@ -58,7 +53,6 @@ public Optional findMemberInfo( Provider provider, String providerUserId ) { - return memberAccountService.findMemberInfo(provider, providerUserId); } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthController.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthController.java index babf529..1734e54 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthController.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthController.java @@ -52,7 +52,6 @@ public MobileTokenResponse token( @Valid @RequestBody MobileTokenRequest request, HttpServletRequest httpRequest ) { - return MobileTokenResponse.from( mobileAuthService.exchange( request.code(), @@ -67,7 +66,6 @@ public MobileRefreshResponse refresh( @Valid @RequestBody MobileRefreshRequest request, HttpServletRequest httpRequest ) { - return MobileRefreshResponse.from( mobileAuthService.refresh( request.refreshToken(), @@ -86,7 +84,6 @@ public MobileTokenResponse signup( @Valid @RequestBody MobileSignupRequest request, HttpServletRequest httpRequest ) { - return MobileTokenResponse.signup( mobileAuthService.signup( request.signupTicket(), @@ -99,7 +96,6 @@ public MobileTokenResponse signup( public MobileAuthorizationResponse consentLink( @Valid @RequestBody MobileLinkConsentRequest request ) { - return new MobileAuthorizationResponse(mobileAuthService.consentLink( request.linkTicket(), request.redirectUri(), @@ -120,7 +116,6 @@ public MobileAuthorizationResponse startSettingsLink( @PathVariable String provider, @Valid @RequestBody MobileLinkStartRequest request ) { - return new MobileAuthorizationResponse(mobileAuthService.startSettingsLink( memberId, provider, diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthService.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthService.java index a6ab04c..854159c 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthService.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileAuthService.java @@ -26,7 +26,6 @@ public String startLogin( String codeChallengeMethod, String state ) { - return mobileOAuthService.startLogin( provider, redirectUri, @@ -41,12 +40,10 @@ public ExchangeResult exchange( String redirectUri, DeviceInfo device ) { - return mobileOAuthService.exchange(code, verifier, redirectUri, device); } public TokenPair refresh(String refreshToken, DeviceInfo device) { - return authService.refreshMobile(refreshToken, device); } @@ -71,7 +68,6 @@ public String consentLink( String codeChallengeMethod, String state ) { - return mobileOAuthService.startLinkReauthentication( linkTicket, redirectUri, @@ -92,7 +88,6 @@ public String startSettingsLink( String codeChallengeMethod, String state ) { - return mobileOAuthService.startSettingsLink( memberId, provider, diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshResponse.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshResponse.java index 39bfdb0..9b3eb56 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileRefreshResponse.java @@ -15,7 +15,6 @@ public static MobileRefreshResponse from( TokenPair tokens, AuthProperties properties ) { - return new MobileRefreshResponse( "Bearer", tokens.accessToken(), diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java b/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java index f19f40f..e050021 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/MobileTokenResponse.java @@ -70,7 +70,6 @@ public static MobileTokenResponse signup( private static User user(MemberInfo member) { if (member == null) { - return null; } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/GuestCredentialResponse.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/GuestCredentialResponse.java index 1739dd1..ede69c4 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/guest/GuestCredentialResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/GuestCredentialResponse.java @@ -5,7 +5,6 @@ public record GuestCredentialResponse(String guestToken, long expiresIn) { public static GuestCredentialResponse from(IssuedGuestCredential credential) { - return new GuestCredentialResponse( credential.token(), credential.expiresIn().toSeconds()); diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestController.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestController.java index df0592b..84cc9b3 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestController.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestController.java @@ -20,7 +20,6 @@ public class MobileGuestController { @PostMapping @ResponseStatus(HttpStatus.CREATED) public GuestCredentialResponse issue(HttpServletRequest request) { - return GuestCredentialResponse.from( credentialService.issue(deviceInfoResolver.resolveIp(request))); } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredential.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredential.java index 0544da4..e410b1f 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredential.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredential.java @@ -53,12 +53,10 @@ public static MobileGuestCredential issue( } public boolean isExpired(Instant now) { - return !expiresAt.isAfter(now); } public boolean isRevoked() { - return revokedAt != null; } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialFilter.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialFilter.java index c606f83..ca917f2 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialFilter.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialFilter.java @@ -38,6 +38,7 @@ protected void doFilterInternal( return; } + if (values.size() != 1 || values.getFirst() == null || values.getFirst().isBlank() || values.getFirst().contains(",")) { reject(request, response, AuthErrorCode.GUEST_CREDENTIAL_INVALID); diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialService.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialService.java index 3b62480..0b5d31a 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialService.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialService.java @@ -54,6 +54,7 @@ public String authenticate(String rawToken) { if (credential.isRevoked()) { throw new NalssiLogException(AuthErrorCode.GUEST_CREDENTIAL_INVALID); } + if (credential.isExpired(Instant.now())) { throw new NalssiLogException(AuthErrorCode.GUEST_CREDENTIAL_EXPIRED); } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiter.java b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiter.java index 17e42a0..d5a935f 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiter.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiter.java @@ -65,11 +65,13 @@ public void check(String clientIp) { fingerprint.substring(0, 12)); throw new NalssiLogException(AuthErrorCode.GUEST_ISSUANCE_UNAVAILABLE); } + if (result > 0) { log.warn("auth.guest.issue_rate_limited scope=ip ip={} count={}", fingerprint.substring(0, 12), result); throw new NalssiLogException(AuthErrorCode.GUEST_ISSUANCE_RATE_LIMITED); } + if (result < 0) { log.warn("auth.guest.issue_rate_limited scope=global count={}", -result); throw new NalssiLogException(AuthErrorCode.GUEST_ISSUANCE_RATE_LIMITED); diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java index 5631e93..2a8fce2 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestRepository.java @@ -16,7 +16,6 @@ public class MobileOAuthAuthorizationRequestRepository @Override public OAuth2AuthorizationRequest loadAuthorizationRequest(HttpServletRequest request) { - return delegate.loadAuthorizationRequest(request); } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java index 00b5d27..22ee758 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthAuthorizationRequestResolver.java @@ -27,7 +27,6 @@ public MobileOAuthAuthorizationRequestResolver( @Override public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { - return attachMobileTransaction( request, customizeProvider(delegate.resolve(request))); @@ -38,7 +37,6 @@ public OAuth2AuthorizationRequest resolve( HttpServletRequest request, String clientRegistrationId ) { - return attachMobileTransaction( request, customizeProvider( @@ -49,7 +47,6 @@ private OAuth2AuthorizationRequest customizeProvider( OAuth2AuthorizationRequest authorizationRequest ) { if (authorizationRequest == null) { - return null; } @@ -57,7 +54,6 @@ private OAuth2AuthorizationRequest customizeProvider( OAuth2ParameterNames.REGISTRATION_ID); if (!AppleOAuthProperties.REGISTRATION_ID.equals(registrationId)) { - return authorizationRequest; } @@ -72,7 +68,6 @@ private OAuth2AuthorizationRequest attachMobileTransaction( OAuth2AuthorizationRequest authorizationRequest ) { if (authorizationRequest == null) { - return null; } @@ -80,9 +75,9 @@ private OAuth2AuthorizationRequest attachMobileTransaction( MobileOAuthRequestAttributes.TRANSACTION_PARAMETER); if (values == null || values.length == 0) { - return authorizationRequest; } + if (values.length != 1 || values[0] == null || values[0].isBlank()) { throw invalidTransaction(); } @@ -106,7 +101,6 @@ private OAuth2AuthorizationRequest attachMobileTransaction( } private OAuth2AuthenticationException invalidTransaction() { - return new OAuth2AuthenticationException( new OAuth2Error("invalid_mobile_transaction"), "Invalid mobile OAuth transaction"); diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthCodeStore.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthCodeStore.java index 7b2ef7f..f8cfdf6 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthCodeStore.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthCodeStore.java @@ -95,9 +95,11 @@ public MobileOAuthGrant consume( if ("REDIRECT_MISMATCH".equals(status)) { throw new NalssiLogException(AuthErrorCode.AUTH_REDIRECT_URI_INVALID); } + if ("PKCE_MISMATCH".equals(status)) { throw new NalssiLogException(AuthErrorCode.AUTH_PKCE_VERIFICATION_FAILED); } + if (!"OK".equals(status)) { throw new NalssiLogException(AuthErrorCode.AUTH_MOBILE_CODE_INVALID); } @@ -112,18 +114,15 @@ public MobileOAuthGrant consume( } private String key(String rawCode) { - return KEY_PREFIX + SecretFingerprint.sha256(rawCode); } private String blank(String value) { - return value == null ? "" : value; } private String value(List values, int index) { if (values == null || index >= values.size() || values.get(index) == null) { - return ""; } @@ -131,17 +130,14 @@ private String value(List values, int index) { } private Long longValue(String value) { - return value == null || value.isBlank() ? null : Long.valueOf(value); } private String nullIfBlank(String value) { - return value == null || value.isBlank() ? null : value; } private > T enumValue(Class type, String value) { - return value == null || value.isBlank() ? null : Enum.valueOf(type, value); } } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthGrant.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthGrant.java index c097dae..c7e5c9e 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthGrant.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthGrant.java @@ -12,19 +12,16 @@ public record MobileOAuthGrant( ) { public static MobileOAuthGrant success(Long memberId, Provider provider) { - return new MobileOAuthGrant( MobileAuthResult.SUCCESS, provider, memberId, null, null, true); } public static MobileOAuthGrant signupRequired(Provider provider, String ticketId) { - return new MobileOAuthGrant( MobileAuthResult.SIGNUP_REQUIRED, provider, null, ticketId, null, false); } public static MobileOAuthGrant linkRequired(Provider provider, String ticketId) { - return new MobileOAuthGrant( MobileAuthResult.LINK_REQUIRED, provider, null, ticketId, null, false); } @@ -34,13 +31,11 @@ public static MobileOAuthGrant linkSuccess( Provider provider, boolean issueTokens ) { - return new MobileOAuthGrant( MobileAuthResult.LINK_SUCCESS, provider, memberId, null, null, issueTokens); } public static MobileOAuthGrant failed(Provider provider, String errorCode) { - return new MobileOAuthGrant( MobileAuthResult.FAILED, provider, null, null, errorCode, false); } diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java index a586721..3b1cc11 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthService.java @@ -123,6 +123,7 @@ public String startSettingsLink( if (member.connectedProviders().contains(provider)) { throw new NalssiLogException(AuthErrorCode.ALREADY_LINKED_PROVIDER); } + validateStart(provider, redirectUri, codeChallenge, codeChallengeMethod, appState); MobileOAuthTransaction transaction = new MobileOAuthTransaction( @@ -148,23 +149,29 @@ public String complete(String transactionId, SocialPrincipal principal) { MobileOAuthTransaction transaction = transactionStore.take(transactionId) .orElseThrow(() -> new NalssiLogException( AuthErrorCode.AUTH_MOBILE_TRANSACTION_EXPIRED)); - MobileOAuthGrant grant; + MobileOAuthGrant grant = resolveGrant(transaction, principal); + + return issueCallback(transaction, grant); + } + private MobileOAuthGrant resolveGrant( + MobileOAuthTransaction transaction, + SocialPrincipal principal + ) { if (transaction.provider() != principal.userInfo().provider()) { - grant = MobileOAuthGrant.failed(transaction.provider(), AuthErrorCode.OAUTH_FAILED.getCode()); - } else { - grant = switch (transaction.purpose()) { - case LOGIN -> completeLogin(principal); - case LOGIN_LINK_REAUTH -> completeLoginLink(transaction, principal); - case SETTINGS_LINK -> completeSettingsLink(transaction, principal); - }; + return MobileOAuthGrant.failed( + transaction.provider(), + AuthErrorCode.OAUTH_FAILED.getCode()); } - return issueCallback(transaction, grant); + return switch (transaction.purpose()) { + case LOGIN -> completeLogin(principal); + case LOGIN_LINK_REAUTH -> completeLoginLink(transaction, principal); + case SETTINGS_LINK -> completeSettingsLink(transaction, principal); + }; } public Optional completeFailure(String transactionId, String errorCode) { - return transactionStore.take(transactionId) .map(transaction -> issueCallback( transaction, @@ -178,6 +185,7 @@ public ExchangeResult exchange( com.nalssilog.auth.device.DeviceInfo device ) { validateRedirectUri(redirectUri); + if (codeVerifier == null || !CODE_VERIFIER_PATTERN.matcher(codeVerifier).matches()) { throw new NalssiLogException(AuthErrorCode.AUTH_PKCE_VERIFICATION_FAILED); } @@ -278,7 +286,6 @@ private MobileOAuthGrant completeLoginLink( && ticketStore.isLinkConsented(ticketId); if (!validOwner) { - return MobileOAuthGrant.failed( transaction.provider(), AuthErrorCode.OAUTH_FAILED.getCode()); } @@ -296,7 +303,6 @@ private MobileOAuthGrant completeLoginLink( ticket.provider(), true); } catch (NalssiLogException _) { - return MobileOAuthGrant.failed( transaction.provider(), AuthErrorCode.OAUTH_FAILED.getCode()); } finally { @@ -321,7 +327,6 @@ private MobileOAuthGrant completeSettingsLink( principal.userInfo().provider(), false); } catch (NalssiLogException _) { - return MobileOAuthGrant.failed( transaction.provider(), AuthErrorCode.OAUTH_FAILED.getCode()); } @@ -394,11 +399,10 @@ private ExchangeResult linkRequired(MobileOAuthGrant grant) { private NalssiLogException failure(String errorCode) { if (AuthErrorCode.OAUTH_CANCELLED.getCode().equals(errorCode)) { - return new NalssiLogException(AuthErrorCode.OAUTH_CANCELLED); } - if (AuthErrorCode.OAUTH_EMAIL_REQUIRED.getCode().equals(errorCode)) { + if (AuthErrorCode.OAUTH_EMAIL_REQUIRED.getCode().equals(errorCode)) { return new NalssiLogException(AuthErrorCode.OAUTH_EMAIL_REQUIRED); } @@ -444,11 +448,13 @@ private void validateStart( String appState ) { validateRedirectUri(redirectUri); + if (!"S256".equals(codeChallengeMethod) || codeChallenge == null || !CODE_CHALLENGE_PATTERN.matcher(codeChallenge).matches()) { throw new NalssiLogException(AuthErrorCode.AUTH_PKCE_VERIFICATION_FAILED); } + if (appState == null || !STATE_PATTERN.matcher(appState).matches()) { throw new NalssiLogException(AuthErrorCode.OAUTH_FAILED); @@ -470,7 +476,6 @@ private void validateRedirectUri(String redirectUri) { private Provider provider(String text) { try { - return Provider.from(text); } catch (IllegalArgumentException exception) { throw new NalssiLogException(AuthErrorCode.UNSUPPORTED_PROVIDER); diff --git a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransactionStore.java b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransactionStore.java index cb8ceff..b600473 100644 --- a/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransactionStore.java +++ b/auth/src/main/java/com/nalssilog/auth/mobile/oauth/MobileOAuthTransactionStore.java @@ -31,7 +31,6 @@ public void save(String transactionId, MobileOAuthTransaction transaction) { public Optional find(String transactionId) { if (!validTransactionId(transactionId)) { - return Optional.empty(); } @@ -40,7 +39,6 @@ public Optional find(String transactionId) { public Optional take(String transactionId) { if (!validTransactionId(transactionId)) { - return Optional.empty(); } @@ -48,19 +46,16 @@ public Optional take(String transactionId) { } private String key(String transactionId) { - return KEY_PREFIX + SecretFingerprint.sha256(transactionId); } private boolean validTransactionId(String transactionId) { - return transactionId != null && TRANSACTION_ID_PATTERN.matcher(transactionId).matches(); } private String serialize(MobileOAuthTransaction transaction) { try { - return objectMapper.writeValueAsString(transaction); } catch (JacksonException exception) { throw new IllegalStateException("mobile OAuth transaction serialization failed", exception); @@ -69,11 +64,10 @@ private String serialize(MobileOAuthTransaction transaction) { private Optional deserialize(String value) { if (value == null) { - return Optional.empty(); } - try { + try { return Optional.of(objectMapper.readValue(value, MobileOAuthTransaction.class)); } catch (JacksonException exception) { throw new IllegalStateException("mobile OAuth transaction deserialization failed", exception); diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java index c39df54..8084d44 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginFailureHandler.java @@ -55,7 +55,6 @@ public void onAuthenticationFailure( private String resolveCode(AuthenticationException exception) { if (exception instanceof OAuth2AuthenticationException oauthException) { - return switch (oauthException.getError().getErrorCode()) { case "access_denied" -> "OAUTH_CANCELLED"; case EMAIL_REQUIRED_ERROR -> "OAUTH_EMAIL_REQUIRED"; diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java index b816c06..639c773 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/OAuth2LoginSuccessHandler.java @@ -69,19 +69,23 @@ private void applyCookies( if (completion.clearLinkIntent()) { cookieManager.clearLinkIntentCookie(response); } + if (completion.clearLinkTicket()) { cookieManager.clearLinkTicketCookie(response); } + if (completion.signupTicket() != null) { cookieManager.addSignupTicketCookie( response, completion.signupTicket()); } + if (completion.linkTicket() != null) { cookieManager.addLinkTicketCookie( response, completion.linkTicket()); } + if (completion.tokens() != null) { cookieManager.addAuthCookies( response, diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/OAuthUserInfo.java b/auth/src/main/java/com/nalssilog/auth/oauth/OAuthUserInfo.java index 8b85fe8..1ed3463 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/OAuthUserInfo.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/OAuthUserInfo.java @@ -17,7 +17,6 @@ public record OAuthUserInfo( @SuppressWarnings("unchecked") public static OAuthUserInfo of(String registrationId, Map attributes) { - return of(registrationId, attributes, null); } @@ -68,12 +67,10 @@ yield new OAuthUserInfo( } private static String firstNonBlank(String first, String second) { - return first == null || first.isBlank() ? second : first; } private static String text(Object value) { - return value == null ? null : String.valueOf(value); } } diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/SocialAuthPrincipal.java b/auth/src/main/java/com/nalssilog/auth/oauth/SocialAuthPrincipal.java index e95a7fd..7cdde0b 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/SocialAuthPrincipal.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/SocialAuthPrincipal.java @@ -22,24 +22,20 @@ public record SocialAuthPrincipal( @Override public Map getAttributes() { - return attributes; } @Override public Collection getAuthorities() { - return List.of(new SimpleGrantedAuthority(roleOf(result.status()))); } @Override public String getName() { - return result.memberId() == null ? "anonymous" : String.valueOf(result.memberId()); } public static String roleOf(MemberStatus status) { - return status == MemberStatus.ACTIVE ? "ROLE_MEMBER" : "ROLE_PENDING"; } } diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/SocialOidcPrincipal.java b/auth/src/main/java/com/nalssilog/auth/oauth/SocialOidcPrincipal.java index 49671bd..395c6d2 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/SocialOidcPrincipal.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/SocialOidcPrincipal.java @@ -23,38 +23,32 @@ public record SocialOidcPrincipal( @Override public Map getClaims() { - return delegate.getClaims(); } @Override public OidcUserInfo getUserInfo() { - return delegate.getUserInfo(); } @Override public OidcIdToken getIdToken() { - return delegate.getIdToken(); } @Override public Map getAttributes() { - return delegate.getAttributes(); } @Override public Collection getAuthorities() { - return List.of(new SimpleGrantedAuthority( SocialAuthPrincipal.roleOf(result.status()))); } @Override public String getName() { - return result.memberId() == null ? delegate.getName() : String.valueOf(result.memberId()); diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java b/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java index 4c7a020..47a7f38 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/WebOAuthService.java @@ -46,7 +46,6 @@ public Completion complete( DeviceInfo device ) { if (linkIntentId.isPresent()) { - return completeSettingsLink(linkIntentId.get(), principal); } @@ -66,7 +65,6 @@ private Completion completeSettingsLink( ticketStore.deleteLinkIntent(intentId); if (memberId.isEmpty()) { - return Completion.clearIntent(LINK_FAILED, null); } @@ -75,7 +73,6 @@ private Completion completeSettingsLink( return Completion.clearIntent(LINK_SUCCESS, null); } catch (NalssiLogException exception) { - return Completion.clearIntent( LINK_FAILED, exception.getErrorCode().getCode()); @@ -88,12 +85,10 @@ private Completion completeExisting( DeviceInfo device ) { if (principal.result().status() == MemberStatus.WITHDRAWN) { - return Completion.redirect(FAILED); } if (pendingLinkTicketId.isPresent()) { - return completeLoginLink( pendingLinkTicketId.get(), principal, @@ -121,7 +116,6 @@ private Completion completeLoginLink( ticketStore.deleteLinkConsent(ticketId); if (ticket == null || !consented) { - return authenticated( principal.result().memberId(), principal.result().status(), @@ -132,7 +126,6 @@ private Completion completeLoginLink( } if (!ticket.targetMemberId().equals(principal.result().memberId())) { - return Completion.clearLinkTicket(LINK_FAILED); } @@ -224,31 +217,26 @@ public record Completion( ) { private static Completion redirect(String result) { - return new Completion( result, null, null, null, null, false, false); } private static Completion clearIntent(String result, String errorCode) { - return new Completion( result, errorCode, null, null, null, true, false); } private static Completion clearLinkTicket(String result) { - return new Completion( result, null, null, null, null, false, true); } private static Completion signup(String ticketId) { - return new Completion( SIGNUP_REQUIRED, null, null, ticketId, null, false, false); } private static Completion link(String ticketId) { - return new Completion( LINK_REQUIRED, null, null, null, ticketId, false, false); } diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilter.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilter.java index 7769ac5..458866b 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilter.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationResponseFilter.java @@ -27,7 +27,6 @@ public class AppleAuthorizationResponseFilter extends OncePerRequestFilter { @Override protected boolean shouldNotFilter(HttpServletRequest request) { - return !HttpMethod.POST.matches(request.getMethod()) || !CALLBACK_PATH.equals(request.getRequestURI()); } @@ -48,9 +47,9 @@ protected void doFilterInternal( private String readSocialName(String payload) { if (payload == null || payload.isBlank()) { - return null; } + if (payload.length() > MAX_USER_JSON_LENGTH) { log.warn("auth.apple.user_payload_rejected reason=too_large"); @@ -61,7 +60,6 @@ private String readSocialName(String payload) { AppleUser user = objectMapper.readValue(payload, AppleUser.class); if (user == null || user.name() == null) { - return null; } @@ -70,7 +68,6 @@ private String readSocialName(String payload) { String combined = (firstName + " " + lastName).strip(); if (combined.isBlank()) { - return null; } @@ -86,7 +83,6 @@ private String readSocialName(String payload) { private String sanitize(String value) { if (value == null) { - return ""; } diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationUserContext.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationUserContext.java index f82cb4b..cb0c82f 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationUserContext.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleAuthorizationUserContext.java @@ -10,13 +10,13 @@ public class AppleAuthorizationUserContext { public void set(String value) { socialName.remove(); + if (value != null && !value.isBlank()) { socialName.set(value); } } public Optional currentSocialName() { - return Optional.ofNullable(socialName.get()); } diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGenerator.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGenerator.java index 20d22d0..38fcc0b 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGenerator.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleClientSecretGenerator.java @@ -25,6 +25,7 @@ public AppleClientSecretGenerator(AppleOAuthProperties properties) { public String generate(String clientId) { properties.requireConfigured(); + if (clientId == null || clientId.isBlank()) { throw new IllegalStateException("Apple OAuth client-id is missing"); } @@ -33,14 +34,13 @@ public String generate(String clientId) { CachedSecret current = cachedSecret; if (isReusable(current, clientId, now)) { - return current.value(); } synchronized (this) { current = cachedSecret; - if (isReusable(current, clientId, now)) { + if (isReusable(current, clientId, now)) { return current.value(); } @@ -70,7 +70,6 @@ private boolean isReusable( String clientId, Instant now ) { - return secret != null && secret.clientId().equals(clientId) && secret.expiresAt() @@ -98,7 +97,6 @@ private PrivateKey privateKey() { private byte[] encodedPrivateKey() { if (properties.privateKey() != null && !properties.privateKey().isBlank()) { - return decodePemOrBase64(properties.privateKey()); } diff --git a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProperties.java b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProperties.java index eff4fc9..3c15f57 100644 --- a/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProperties.java +++ b/auth/src/main/java/com/nalssilog/auth/oauth/apple/AppleOAuthProperties.java @@ -20,15 +20,18 @@ public record AppleOAuthProperties( if (clientSecretTtl == null) { clientSecretTtl = Duration.ofDays(30); } + if (clientSecretTtl.isNegative() || clientSecretTtl.isZero() || clientSecretTtl.compareTo(MAX_CLIENT_SECRET_TTL) > 0) { throw new IllegalArgumentException( "Apple OAuth client-secret-ttl must be between 1ms and 180 days"); } + if (refreshBeforeExpiry == null) { refreshBeforeExpiry = Duration.ofDays(1); } + if (refreshBeforeExpiry.isNegative() || refreshBeforeExpiry.compareTo(clientSecretTtl) >= 0) { throw new IllegalArgumentException( @@ -39,6 +42,7 @@ public record AppleOAuthProperties( public void requireConfigured() { requireText(teamId, "team-id"); requireText(keyId, "key-id"); + if (!hasText(privateKey) && !hasText(privateKeyBase64)) { throw new IllegalStateException( "Apple OAuth is active but neither private-key nor " @@ -55,13 +59,11 @@ private void requireText(String value, String property) { } private boolean hasText(String value) { - return value != null && !value.isBlank(); } @Override public String toString() { - return "AppleOAuthProperties[teamId=, keyId=, " + "privateKey=, privateKeyBase64=, clientSecretTtl=" + clientSecretTtl diff --git a/auth/src/main/java/com/nalssilog/auth/security/ApiAuthenticationEntryPoint.java b/auth/src/main/java/com/nalssilog/auth/security/ApiAuthenticationEntryPoint.java index 3df9761..84c7aec 100644 --- a/auth/src/main/java/com/nalssilog/auth/security/ApiAuthenticationEntryPoint.java +++ b/auth/src/main/java/com/nalssilog/auth/security/ApiAuthenticationEntryPoint.java @@ -34,10 +34,12 @@ public void commence( response.setCharacterEncoding("UTF-8"); response.setHeader(HttpHeaders.CACHE_CONTROL, "no-store"); response.setHeader(HttpHeaders.PRAGMA, "no-cache"); + if (exception instanceof CredentialAuthenticationException credentialException && credentialException.isBearer()) { response.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer"); } + objectMapper.writeValue( response.getWriter(), new ErrorResponse(errorCode.getCode(), errorCode.getMessage())); diff --git a/auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java b/auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java index 4429ae2..87e5522 100644 --- a/auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java +++ b/auth/src/main/java/com/nalssilog/auth/security/AuthSecurityConfig.java @@ -21,6 +21,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.security.access.AccessDeniedException; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -114,18 +115,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .failureHandler(oAuth2LoginFailureHandler)) .exceptionHandling(exception -> exception .authenticationEntryPoint(apiAuthenticationEntryPoint) - .accessDeniedHandler((request, response, accessDeniedException) -> { - if (accessDeniedException instanceof MissingCsrfTokenException) { - writeError(response, HttpServletResponse.SC_FORBIDDEN, - "CSRF_TOKEN_MISSING", "CSRF 토큰이 없습니다."); - } else if (accessDeniedException instanceof InvalidCsrfTokenException) { - writeError(response, HttpServletResponse.SC_FORBIDDEN, - "CSRF_TOKEN_INVALID", "CSRF 토큰이 유효하지 않습니다."); - } else { - writeError(response, HttpServletResponse.SC_FORBIDDEN, - "ACCESS_DENIED", "접근 권한이 없습니다."); - } - })) + .accessDeniedHandler((request, response, accessDeniedException) -> + writeAccessDeniedError(response, accessDeniedException))) .addFilterBefore(jwtAuthenticationFilter, CsrfFilter.class) .addFilterAfter(mobileGuestCredentialFilter, JwtAuthenticationFilter.class) .addFilterBefore( @@ -181,12 +172,33 @@ private boolean isMobileCredentialEndpoint(HttpServletRequest request) { } private boolean isAppleAuthorizationCallback(HttpServletRequest request) { - return HttpMethod.POST.matches(request.getMethod()) && AppleAuthorizationResponseFilter.CALLBACK_PATH.equals( request.getRequestURI()); } + private void writeAccessDeniedError( + HttpServletResponse response, + AccessDeniedException exception + ) throws IOException { + if (exception instanceof MissingCsrfTokenException) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "CSRF_TOKEN_MISSING", "CSRF 토큰이 없습니다."); + + return; + } + + if (exception instanceof InvalidCsrfTokenException) { + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "CSRF_TOKEN_INVALID", "CSRF 토큰이 유효하지 않습니다."); + + return; + } + + writeError(response, HttpServletResponse.SC_FORBIDDEN, + "ACCESS_DENIED", "접근 권한이 없습니다."); + } + private void writeError( HttpServletResponse response, int status, diff --git a/auth/src/main/java/com/nalssilog/auth/ticket/AuthTicketStore.java b/auth/src/main/java/com/nalssilog/auth/ticket/AuthTicketStore.java index 8f29025..9a622d7 100644 --- a/auth/src/main/java/com/nalssilog/auth/ticket/AuthTicketStore.java +++ b/auth/src/main/java/com/nalssilog/auth/ticket/AuthTicketStore.java @@ -48,7 +48,6 @@ public void saveSignup(String ticketId, SignupTicket ticket, Duration ttl) { } public Optional findSignup(String ticketId) { - return read(SIGNUP_PREFIX + ticketId, SignupTicket.class); } @@ -60,7 +59,6 @@ public SignupClaim claimSignup(String ticketId, String claimId, Duration ttl) { Optional completed = findSignupCompletion(ticketId); if (completed.isPresent()) { - return SignupClaim.completed(completed.get()); } @@ -70,7 +68,6 @@ public SignupClaim claimSignup(String ticketId, String claimId, Duration ttl) { ttl); if (!Boolean.TRUE.equals(acquired)) { - return findSignupCompletion(ticketId) .map(SignupClaim::completed) .orElseGet(SignupClaim::inProgress); @@ -118,7 +115,6 @@ public void releaseSignupClaim(String ticketId, String claimId) { } public Optional findSignupCompletion(String ticketId) { - return read(SIGNUP_COMPLETED_PREFIX + ticketId, SignupCompletion.class); } @@ -127,7 +123,6 @@ public void saveLink(String ticketId, LinkTicket ticket, Duration ttl) { } public Optional findLink(String ticketId) { - return read(LINK_PREFIX + ticketId, LinkTicket.class); } @@ -141,7 +136,6 @@ public void markLinkConsented(String ticketId, Duration ttl) { } public boolean isLinkConsented(String ticketId) { - return Boolean.TRUE.equals(redisTemplate.hasKey(LINK_CONSENT_PREFIX + ticketId)); } @@ -168,12 +162,10 @@ private Optional read(String key, Class type) { String value = redisTemplate.opsForValue().get(key); if (value == null) { - return Optional.empty(); } try { - return Optional.of(OBJECT_MAPPER.readValue(value, type)); } catch (JacksonException e) { throw new IllegalStateException("인증 티켓 역직렬화 실패", e); @@ -182,7 +174,6 @@ private Optional read(String key, Class type) { private String serialize(Object ticket) { try { - return OBJECT_MAPPER.writeValueAsString(ticket); } catch (JacksonException e) { throw new IllegalStateException("인증 티켓 직렬화 실패", e); @@ -213,22 +204,18 @@ public record SignupClaim( ) { private static SignupClaim claimed(SignupTicket ticket) { - return new SignupClaim(SignupClaimStatus.CLAIMED, ticket, null); } private static SignupClaim completed(SignupCompletion completion) { - return new SignupClaim(SignupClaimStatus.COMPLETED, null, completion); } private static SignupClaim inProgress() { - return new SignupClaim(SignupClaimStatus.IN_PROGRESS, null, null); } private static SignupClaim missing() { - return new SignupClaim(SignupClaimStatus.MISSING, null, null); } } diff --git a/auth/src/main/java/com/nalssilog/auth/ticket/LinkTicket.java b/auth/src/main/java/com/nalssilog/auth/ticket/LinkTicket.java index 8e9acbc..e0e6a14 100644 --- a/auth/src/main/java/com/nalssilog/auth/ticket/LinkTicket.java +++ b/auth/src/main/java/com/nalssilog/auth/ticket/LinkTicket.java @@ -28,7 +28,6 @@ public LinkTicket( } public AuthChannel effectiveChannel() { - return channel == null ? AuthChannel.WEB : channel; } } diff --git a/auth/src/main/java/com/nalssilog/auth/ticket/SignupTicket.java b/auth/src/main/java/com/nalssilog/auth/ticket/SignupTicket.java index 9baf605..238d892 100644 --- a/auth/src/main/java/com/nalssilog/auth/ticket/SignupTicket.java +++ b/auth/src/main/java/com/nalssilog/auth/ticket/SignupTicket.java @@ -24,7 +24,6 @@ public SignupTicket( } public AuthChannel effectiveChannel() { - return channel == null ? AuthChannel.WEB : channel; } } diff --git a/auth/src/main/java/com/nalssilog/auth/token/AuthSessionService.java b/auth/src/main/java/com/nalssilog/auth/token/AuthSessionService.java index 0b0c599..4de4e35 100644 --- a/auth/src/main/java/com/nalssilog/auth/token/AuthSessionService.java +++ b/auth/src/main/java/com/nalssilog/auth/token/AuthSessionService.java @@ -20,7 +20,6 @@ public class AuthSessionService { private final AuthProperties properties; public List listSessions(Long memberId, String currentSessionId) { - return refreshTokenStore.findSessionsByMember(memberId).stream() .sorted(Comparator.comparing(SessionData::lastActiveAt).reversed()) .map(session -> new SessionView( diff --git a/auth/src/main/java/com/nalssilog/auth/token/AuthTokenService.java b/auth/src/main/java/com/nalssilog/auth/token/AuthTokenService.java index ff347c6..50e4f11 100644 --- a/auth/src/main/java/com/nalssilog/auth/token/AuthTokenService.java +++ b/auth/src/main/java/com/nalssilog/auth/token/AuthTokenService.java @@ -56,7 +56,6 @@ public TokenPair refresh(String refreshToken, DeviceInfo device) { Optional current = refreshTokenStore.findSession(currentHash); if (current.isEmpty()) { - return resolveInactiveRefresh(currentHash); } @@ -111,7 +110,6 @@ public void revoke(String refreshToken) { /** refresh 토큰의 세션 키(해시). '현재 세션' 판별용. */ public String tokenHash(String refreshToken) { - return hash(refreshToken); } @@ -126,11 +124,10 @@ 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())) { + if (refreshTokenStore.isSessionRevoked(used.sessionId())) { return rejectExpired(currentHash, "session_revoked"); } @@ -138,7 +135,6 @@ private TokenPair resolveInactiveRefresh(String currentHash) { SessionData replacement = refreshTokenStore.findSession(used.replacementHash()).orElse(null); if (replacement == null) { - return rejectReuse( new RotationResult(RotationStatus.REUSED, "", used.replacementHash(), used.memberId(), used.sessionId(), 0), @@ -182,17 +178,21 @@ private TokenPair retry(RotationResult result, String currentHash) { } 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 { + if (result.memberId() == null + || result.sessionId() == null + || result.sessionId().isBlank()) { log.warn("auth.refresh.reuse_detected memberId=unknown sessionId=unknown token={}", fingerprint(currentHash)); + + throw new NalssiLogException(AuthErrorCode.AUTH_REFRESH_REUSED); } + 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)); + throw new NalssiLogException(AuthErrorCode.AUTH_REFRESH_REUSED); } @@ -207,7 +207,6 @@ private NalssiLogException expired(String currentHash, String reason) { } private MemberInfo activeMember(Long memberId, String sessionId, String tokenHash) { - return memberClient.findMemberInfo(memberId).orElseThrow(() -> { refreshTokenStore.revokeSession(memberId, sessionId, properties.jwt().refreshTokenTtl()); @@ -231,7 +230,6 @@ private TokenPair issueWithSession(Long memberId, MemberStatus status, Provider private SessionData sessionData(String tokenHash, Long memberId, Provider provider, String sessionId, Instant loginAt, DeviceInfo device) { - return new SessionData( tokenHash, sessionId, @@ -250,7 +248,6 @@ private TokenPair tokenPair( String refreshToken, Duration maxAge ) { - return new TokenPair( jwtTokenProvider.createAccessToken(member.id(), member.status(), provider, sessionId), refreshToken, @@ -266,7 +263,6 @@ private String generateRefreshToken() { } private String fingerprint(String tokenHash) { - return tokenHash.substring(0, Math.min(FINGERPRINT_LENGTH, tokenHash.length())); } diff --git a/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java b/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java index fa2f4f9..d9127d0 100644 --- a/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java +++ b/auth/src/main/java/com/nalssilog/auth/token/JwtAuthenticationFilter.java @@ -53,11 +53,17 @@ protected void doFilterInternal( if (!authorizationHeaders.isEmpty()) { authenticateBearer(request, response, authorizationHeaders); - if (response.isCommitted()) { + if (response.isCommitted()) { return; } - } else if (allowsCookieFallback(request)) { + + filterChain.doFilter(request, response); + + return; + } + + if (allowsCookieFallback(request)) { authenticateCookie(request); } @@ -90,12 +96,14 @@ private void authenticateBearer( return; } + if (validation.status() != TokenValidationStatus.VALID || !isUsable(validation.payload())) { reject(request, response, AuthErrorCode.AUTH_ACCESS_TOKEN_INVALID); return; } + if (isRevoked(validation.payload())) { reject(request, response, AuthErrorCode.AUTH_SESSION_EXPIRED); @@ -115,12 +123,10 @@ private void authenticateCookie(HttpServletRequest request) { } private boolean isUsable(AccessTokenPayload payload) { - return payload != null && payload.status() != MemberStatus.WITHDRAWN; } private boolean isRevoked(AccessTokenPayload payload) { - return payload.sessionId() != null && !payload.sessionId().isBlank() && refreshTokenStore.isSessionRevoked(payload.sessionId()); diff --git a/auth/src/main/java/com/nalssilog/auth/token/JwtTokenProvider.java b/auth/src/main/java/com/nalssilog/auth/token/JwtTokenProvider.java index 94f7a21..90f3812 100644 --- a/auth/src/main/java/com/nalssilog/auth/token/JwtTokenProvider.java +++ b/auth/src/main/java/com/nalssilog/auth/token/JwtTokenProvider.java @@ -29,17 +29,21 @@ public class JwtTokenProvider { public JwtTokenProvider(AuthProperties properties) { String secret = properties.jwt().secret(); + this.key = resolveKey(secret); + this.accessTokenTtl = properties.jwt().accessTokenTtl(); + } + + private static SecretKey resolveKey(String secret) { if (secret == null || secret.isBlank()) { log.warn("JWT secret 이 설정되지 않아 임시 키를 생성합니다. 재시작하면 모든 액세스 토큰이 무효화됩니다."); - this.key = Jwts.SIG.HS256.key().build(); - } else { - this.key = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + + return Jwts.SIG.HS256.key().build(); } - this.accessTokenTtl = properties.jwt().accessTokenTtl(); + + return Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); } public String createAccessToken(Long memberId, MemberStatus status, Provider provider) { - return createAccessToken(memberId, status, provider, null); } @@ -89,7 +93,6 @@ public TokenValidation validate(String token) { if (status == null || provider == null || (tokenType != null && !"access".equals(tokenType))) { - return TokenValidation.invalid(); } @@ -100,10 +103,8 @@ public TokenValidation validate(String token) { claims.get("sid", String.class) )); } catch (ExpiredJwtException _) { - return TokenValidation.expired(); } catch (JwtException | IllegalArgumentException _) { - return TokenValidation.invalid(); } } @@ -117,17 +118,14 @@ public enum TokenValidationStatus { public record TokenValidation(TokenValidationStatus status, AccessTokenPayload payload) { private static TokenValidation valid(AccessTokenPayload payload) { - return new TokenValidation(TokenValidationStatus.VALID, payload); } private static TokenValidation expired() { - return new TokenValidation(TokenValidationStatus.EXPIRED, null); } private static TokenValidation invalid() { - return new TokenValidation(TokenValidationStatus.INVALID, null); } } diff --git a/auth/src/main/java/com/nalssilog/auth/token/RefreshTokenStore.java b/auth/src/main/java/com/nalssilog/auth/token/RefreshTokenStore.java index 351c813..2405037 100644 --- a/auth/src/main/java/com/nalssilog/auth/token/RefreshTokenStore.java +++ b/auth/src/main/java/com/nalssilog/auth/token/RefreshTokenStore.java @@ -198,12 +198,10 @@ public Optional findSession(String tokenHash) { Map raw = redisTemplate.opsForHash().entries(key(tokenHash)); if (raw.isEmpty()) { - return Optional.empty(); } try { - return Optional.of(toSessionData(tokenHash, raw)); } catch (IllegalArgumentException _) { // provider 필드가 없던 구버전 세션은 실제 인증 수단을 보장할 수 없으므로 재로그인시킨다. @@ -216,25 +214,21 @@ 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))); } @@ -275,6 +269,7 @@ public long deleteAllByMember(Long memberId, Duration markerTtl) { for (String sessionId : sessionIds) { deleted += revokeSession(memberId, sessionId, markerTtl); } + redisTemplate.delete(memberKey(memberId)); return deleted; @@ -285,7 +280,6 @@ public List findSessionsByMember(Long memberId) { Set hashes = redisTemplate.opsForSet().members(memberKey(memberId)); if (hashes == null || hashes.isEmpty()) { - return List.of(); } @@ -300,7 +294,6 @@ public List findSessionsByMember(Long memberId) { private RotationResult toRotationResult(List raw) { if (raw == null || raw.isEmpty()) { - return RotationResult.missing(); } @@ -309,7 +302,6 @@ private RotationResult toRotationResult(List raw) { try { status = RotationStatus.valueOf(value(raw, 0)); } catch (IllegalArgumentException _) { - return RotationResult.missing(); } @@ -323,7 +315,6 @@ private RotationResult toRotationResult(List raw) { } private SessionData toSessionData(String tokenHash, Map raw) { - return new SessionData( tokenHash, str(raw, FIELD_SESSION_ID), @@ -343,7 +334,6 @@ private String str(Map raw, String field) { private String value(List raw, int index) { if (index >= raw.size() || raw.get(index) == null) { - return ""; } @@ -363,27 +353,22 @@ private long primitiveLongValue(List raw, int index) { } private String key(String tokenHash) { - return KEY_PREFIX + 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; } @@ -404,7 +389,6 @@ public record RotationResult( long refreshTokenTtlMillis ) { private static RotationResult missing() { - return new RotationResult(RotationStatus.MISSING, "", "", null, "", 0); } } diff --git a/auth/src/main/java/com/nalssilog/auth/web/AuthController.java b/auth/src/main/java/com/nalssilog/auth/web/AuthController.java index 3de3980..24a1e7e 100644 --- a/auth/src/main/java/com/nalssilog/auth/web/AuthController.java +++ b/auth/src/main/java/com/nalssilog/auth/web/AuthController.java @@ -114,7 +114,6 @@ public List sessions( @AuthenticationPrincipal Long memberId, @CurrentSecurityContext(expression = "authentication.details.sessionId") String currentSessionId ) { - return authService.sessions(memberId, currentSessionId).stream() .map(SessionResponse::from) .toList(); @@ -154,7 +153,6 @@ public LinkConsentResponse startSocialLink( /** 로그인-시점 연동 동의 + 기존 수단 재인증 URL 반환. */ @PostMapping("/link/consent") public LinkConsentResponse consentLink(HttpServletRequest request) { - return new LinkConsentResponse(authService.consentLink( cookieManager.readLinkTicket(request).orElse(null))); } diff --git a/auth/src/main/java/com/nalssilog/auth/web/AuthCookieManager.java b/auth/src/main/java/com/nalssilog/auth/web/AuthCookieManager.java index 9b185b3..c07291e 100644 --- a/auth/src/main/java/com/nalssilog/auth/web/AuthCookieManager.java +++ b/auth/src/main/java/com/nalssilog/auth/web/AuthCookieManager.java @@ -49,12 +49,10 @@ public void clearAuthCookies(HttpServletResponse response) { } public Optional readAccessToken(HttpServletRequest request) { - return readCookie(request, ACCESS_TOKEN_COOKIE); } public Optional readRefreshToken(HttpServletRequest request) { - return readCookie(request, REFRESH_TOKEN_COOKIE); } @@ -67,7 +65,6 @@ public void clearSignupTicketCookie(HttpServletResponse response) { } public Optional readSignupTicket(HttpServletRequest request) { - return readCookie(request, SIGNUP_TICKET_COOKIE); } @@ -80,7 +77,6 @@ public void clearLinkTicketCookie(HttpServletResponse response) { } public Optional readLinkTicket(HttpServletRequest request) { - return readCookie(request, LINK_TICKET_COOKIE); } @@ -93,7 +89,6 @@ public void clearLinkIntentCookie(HttpServletResponse response) { } public Optional readLinkIntent(HttpServletRequest request) { - return readCookie(request, LINK_INTENT_COOKIE); } @@ -112,7 +107,6 @@ private void addCookie(HttpServletResponse response, String name, String value, private Optional readCookie(HttpServletRequest request, String name) { if (request.getCookies() == null) { - return Optional.empty(); } diff --git a/auth/src/main/java/com/nalssilog/auth/web/MeResponse.java b/auth/src/main/java/com/nalssilog/auth/web/MeResponse.java index f54534c..03dde93 100644 --- a/auth/src/main/java/com/nalssilog/auth/web/MeResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/web/MeResponse.java @@ -38,23 +38,19 @@ public static MeResponse authenticated(MemberInfo member) { } public static MeResponse signupRequired(Provider provider, String email) { - return new MeResponse(false, AuthResult.SIGNUP_REQUIRED, null, new PendingAuth(provider, email, List.of())); } public static MeResponse linkRequired(Provider provider, String email, List existingProviders) { - return new MeResponse(false, AuthResult.LINK_REQUIRED, null, new PendingAuth(provider, email, existingProviders)); } public static MeResponse none() { - return new MeResponse(false, AuthResult.NONE, null, null); } public static MeResponse from(MeState state) { - return switch (state.status()) { case AUTHENTICATED -> authenticated(state.member()); case SIGNUP_REQUIRED -> signupRequired(state.provider(), state.email()); diff --git a/auth/src/main/java/com/nalssilog/auth/web/SessionResponse.java b/auth/src/main/java/com/nalssilog/auth/web/SessionResponse.java index 7de699a..651b3ef 100644 --- a/auth/src/main/java/com/nalssilog/auth/web/SessionResponse.java +++ b/auth/src/main/java/com/nalssilog/auth/web/SessionResponse.java @@ -17,7 +17,6 @@ public record SessionResponse( ) { public static SessionResponse from(SessionView view) { - return new SessionResponse( view.sessionId(), view.deviceName(), diff --git a/auth/src/test/java/com/nalssilog/auth/core/AuthServiceSignupTest.java b/auth/src/test/java/com/nalssilog/auth/core/AuthServiceSignupTest.java index 51f945a..463eac6 100644 --- a/auth/src/test/java/com/nalssilog/auth/core/AuthServiceSignupTest.java +++ b/auth/src/test/java/com/nalssilog/auth/core/AuthServiceSignupTest.java @@ -68,7 +68,7 @@ void completedSignupRetryReturnsTheSameTokenPair() { List.of(), new DeviceInfo( "ANDROID · Galaxy · 0.1.0", - "203.0.113.10")); + "client-a.test")); assertThat(result.tokens().accessToken()) .isEqualTo("same-access-token"); @@ -84,7 +84,7 @@ void completedSignupRetryReturnsTheSameTokenPair() { void mobileRefreshFailureDoesNotRequestWebCookieDeletion() { DeviceInfo device = new DeviceInfo( "ANDROID · Galaxy · 0.1.0", - "203.0.113.10"); + "client-a.test"); when(tokenService.refresh("refresh-token", device)) .thenThrow(new NalssiLogException( @@ -101,7 +101,6 @@ void mobileRefreshFailureDoesNotRequestWebCookieDeletion() { } private MemberInfo member() { - return new MemberInfo( 7L, "사용자", @@ -115,7 +114,6 @@ private MemberInfo member() { } private AuthProperties properties() { - return new AuthProperties( new AuthProperties.Jwt( "test-secret-must-be-at-least-thirty-two-bytes", diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupServiceTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupServiceTest.java index 8bef85d..a0302c5 100644 --- a/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupServiceTest.java +++ b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialCleanupServiceTest.java @@ -31,7 +31,6 @@ void deletesOnlyCredentialsPastTheConfiguredRetention() { } private AuthProperties properties() { - return new AuthProperties( new AuthProperties.Jwt( "test-secret-must-be-at-least-thirty-two-bytes", diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialServiceTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialServiceTest.java index e3aa68a..95e2d1d 100644 --- a/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialServiceTest.java +++ b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestCredentialServiceTest.java @@ -28,11 +28,11 @@ class MobileGuestCredentialServiceTest { @Test void issueReturnsRawSecretOnlyOnceAndPersistsItsHash() { - var issued = service.issue("203.0.113.10"); + var issued = service.issue("client-a.test"); ArgumentCaptor credentialCaptor = ArgumentCaptor.forClass(MobileGuestCredential.class); - verify(rateLimiter).check("203.0.113.10"); + verify(rateLimiter).check("client-a.test"); verify(repository).save(credentialCaptor.capture()); MobileGuestCredential persisted = credentialCaptor.getValue(); @@ -64,7 +64,6 @@ void authenticateResolvesTheInternalAnonymousKey() { } private AuthProperties properties() { - return new AuthProperties( new AuthProperties.Jwt( "test-secret-must-be-at-least-thirty-two-bytes", diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiterTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiterTest.java index 4968086..5495dca 100644 --- a/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiterTest.java +++ b/auth/src/test/java/com/nalssilog/auth/mobile/guest/MobileGuestIssuanceRateLimiterTest.java @@ -35,7 +35,7 @@ void sharedIpUsesHmacFingerprintAndGenerousHardLimit() { any(), any())).thenReturn(0L); - limiter.check("203.0.113.10"); + limiter.check("client-a.test"); ArgumentCaptor> keys = ArgumentCaptor.forClass(List.class); @@ -48,7 +48,7 @@ void sharedIpUsesHmacFingerprintAndGenerousHardLimit() { org.mockito.ArgumentMatchers.eq("3000")); assertThat(keys.getValue()) .hasSize(2) - .allMatch(key -> !key.contains("203.0.113.10")); + .allMatch(key -> !key.contains("client-a.test")); assertThat(keys.getValue().getFirst()).startsWith("auth:guest:issue:ip:"); assertThat(keys.getValue().getLast()).isEqualTo("auth:guest:issue:global"); } @@ -65,7 +65,7 @@ void positiveScriptResultMeansIpHardLimitWasExceeded() { NalssiLogException exception = catchThrowableOfType( NalssiLogException.class, - () -> limiter.check("203.0.113.10")); + () -> limiter.check("client-a.test")); assertThat(exception.getErrorCode()) .isEqualTo(AuthErrorCode.GUEST_ISSUANCE_RATE_LIMITED); @@ -83,14 +83,13 @@ void negativeScriptResultMeansGlobalSafetyLimitWasExceeded() { NalssiLogException exception = catchThrowableOfType( NalssiLogException.class, - () -> limiter.check("198.51.100.8")); + () -> limiter.check("client-b.test")); assertThat(exception.getErrorCode()) .isEqualTo(AuthErrorCode.GUEST_ISSUANCE_RATE_LIMITED); } private AuthProperties properties() { - return new AuthProperties( new AuthProperties.Jwt( "test-secret-must-be-at-least-thirty-two-bytes", @@ -105,7 +104,7 @@ private AuthProperties properties() { Duration.ofMinutes(10), Duration.ofSeconds(90), "test-hmac-secret", - List.of("127.0.0.0/8")), + List.of()), new AuthProperties.Guest( Duration.ofDays(365), 300, diff --git a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java index 38701e7..8cdb282 100644 --- a/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java +++ b/auth/src/test/java/com/nalssilog/auth/mobile/oauth/MobileOAuthServiceTest.java @@ -154,7 +154,7 @@ void tokensAreCreatedOnlyAfterSuccessfulCodeExchange() { String verifier = "v".repeat(43); DeviceInfo device = new DeviceInfo( "ANDROID · Galaxy · 0.1.0", - "203.0.113.10"); + "client-a.test"); MemberInfo member = member(); TokenPair tokens = new TokenPair( "access-token", @@ -197,7 +197,7 @@ void loginLinkIssuesSessionForNewlyLinkedProvider() { String verifier = "v".repeat(43); DeviceInfo device = new DeviceInfo( "IOS · iPhone · 0.1.0", - "203.0.113.10"); + "client-a.test"); MobileOAuthTransaction transaction = new MobileOAuthTransaction( MobileOAuthPurpose.LOGIN_LINK_REAUTH, Provider.NAVER, @@ -309,7 +309,6 @@ void unregisteredRedirectIsRejectedBeforeOAuthStateIsCreated() { } private MemberInfo member() { - return new MemberInfo( 7L, "사용자", @@ -323,7 +322,6 @@ private MemberInfo member() { } private MemberInfo linkedMember() { - return new MemberInfo( 7L, "사용자", @@ -337,7 +335,6 @@ private MemberInfo linkedMember() { } private AuthProperties properties() { - return new AuthProperties( new AuthProperties.Jwt( "test-secret-must-be-at-least-thirty-two-bytes", @@ -352,7 +349,7 @@ private AuthProperties properties() { Duration.ofMinutes(10), Duration.ofSeconds(90), "test-hmac-secret", - List.of("127.0.0.0/8")), + List.of()), new AuthProperties.Guest( Duration.ofDays(365), 5, diff --git a/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java b/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java index e969c97..721abdd 100644 --- a/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java +++ b/auth/src/test/java/com/nalssilog/auth/oauth/WebOAuthServiceTest.java @@ -39,7 +39,7 @@ class WebOAuthServiceTest { @Test void loginLinkIssuesSessionForNewlyLinkedProvider() { - DeviceInfo device = new DeviceInfo("Chrome", "203.0.113.10"); + DeviceInfo device = new DeviceInfo("Chrome", "client-a.test"); LinkTicket ticket = new LinkTicket( Provider.KAKAO, "new-kakao-user", @@ -102,7 +102,6 @@ void loginLinkIssuesSessionForNewlyLinkedProvider() { } private MemberInfo member() { - return new MemberInfo( 7L, "구름산책", diff --git a/auth/src/test/java/com/nalssilog/auth/token/AuthTokenServiceTest.java b/auth/src/test/java/com/nalssilog/auth/token/AuthTokenServiceTest.java index cfa9dcd..73c0a38 100644 --- a/auth/src/test/java/com/nalssilog/auth/token/AuthTokenServiceTest.java +++ b/auth/src/test/java/com/nalssilog/auth/token/AuthTokenServiceTest.java @@ -58,7 +58,7 @@ void setUp() { 1L, Provider.KAKAO, "Chrome · Windows", - "127.0.0.1", + "loopback.test", Instant.parse("2026-07-01T00:00:00Z"), Instant.parse("2026-07-01T00:00:00Z")); member = new MemberInfo( @@ -99,7 +99,7 @@ void rotatesCurrentRefreshTokenThroughAtomicStoreOperation() { Duration.ofDays(14).toMillis()); }); - TokenPair tokens = service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "203.0.113.1")); + TokenPair tokens = service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "client-b.test")); assertThat(tokens.accessToken()).isEqualTo("access-token"); assertThat(tokens.refreshToken()).isNotBlank().isNotEqualTo(CURRENT_TOKEN); @@ -122,7 +122,7 @@ void duplicateWithinGraceReplaysTheFirstReplacementToken() { 1L, Provider.KAKAO, "Chrome · Windows", - "203.0.113.1", + "client-b.test", current.loginAt(), Instant.now()); @@ -145,7 +145,7 @@ void duplicateWithinGraceReplaysTheFirstReplacementToken() { 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")); + TokenPair tokens = service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "client-b.test")); assertThat(tokens.refreshToken()).isEqualTo("replacement-token"); assertThat(tokens.accessToken()).isEqualTo("access-token"); @@ -162,7 +162,7 @@ void reuseOutsideGraceRevokesTheWholeDeviceSession() { 1L, Provider.KAKAO, "Chrome · Windows", - "203.0.113.1", + "client-b.test", current.loginAt(), Instant.now()); @@ -187,7 +187,7 @@ void reuseOutsideGraceRevokesTheWholeDeviceSession() { NalssiLogException exception = catchThrowableOfType( NalssiLogException.class, - () -> service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "203.0.113.1"))); + () -> service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "client-b.test"))); assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.AUTH_REFRESH_REUSED); verify(refreshTokenStore).revokeSession(1L, SESSION_ID, Duration.ofDays(14)); @@ -200,14 +200,13 @@ void unknownRefreshTokenIsRejectedWithoutRevokingUnrelatedSessions() { NalssiLogException exception = catchThrowableOfType( NalssiLogException.class, - () -> service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "203.0.113.1"))); + () -> service.refresh(CURRENT_TOKEN, new DeviceInfo("ignored", "client-b.test"))); 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", diff --git a/auth/src/test/java/com/nalssilog/auth/token/JwtTokenProviderTest.java b/auth/src/test/java/com/nalssilog/auth/token/JwtTokenProviderTest.java index fd015b6..cee52b6 100644 --- a/auth/src/test/java/com/nalssilog/auth/token/JwtTokenProviderTest.java +++ b/auth/src/test/java/com/nalssilog/auth/token/JwtTokenProviderTest.java @@ -41,7 +41,6 @@ void accessTokenCarriesTheServerIssuedSessionId() { } private AuthProperties properties() { - return new AuthProperties( new AuthProperties.Jwt( "test-secret-must-be-at-least-thirty-two-bytes", diff --git a/auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreTest.java b/auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreTest.java index d221d55..577d341 100644 --- a/auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreTest.java +++ b/auth/src/test/java/com/nalssilog/auth/token/RefreshTokenStoreTest.java @@ -38,7 +38,7 @@ void mapsAtomicRotationScriptResultToDomainContract() { 1L, Provider.KAKAO, "Chrome · Windows", - "203.0.113.1", + "client-b.test", Instant.parse("2026-07-01T00:00:00Z"), Instant.parse("2026-07-24T00:00:00Z")); diff --git a/auth/src/test/java/com/nalssilog/auth/token/TestAuthProperties.java b/auth/src/test/java/com/nalssilog/auth/token/TestAuthProperties.java index 3fa946b..401e312 100644 --- a/auth/src/test/java/com/nalssilog/auth/token/TestAuthProperties.java +++ b/auth/src/test/java/com/nalssilog/auth/token/TestAuthProperties.java @@ -9,12 +9,10 @@ private TestAuthProperties() { } static AuthProperties create() { - return create(Duration.ofMinutes(5)); } static AuthProperties create(Duration accessTokenTtl) { - return new AuthProperties( new AuthProperties.Jwt( "test-secret-must-be-at-least-thirty-two-bytes", diff --git a/auth/src/test/java/com/nalssilog/auth/web/AuthControllerTest.java b/auth/src/test/java/com/nalssilog/auth/web/AuthControllerTest.java index 518312c..1173cf7 100644 --- a/auth/src/test/java/com/nalssilog/auth/web/AuthControllerTest.java +++ b/auth/src/test/java/com/nalssilog/auth/web/AuthControllerTest.java @@ -157,7 +157,6 @@ void refreshUsesServerRemainingTtlForHardenedHostOnlyCookie() { } private MemberInfo member() { - return new MemberInfo( 1L, "닉네임", @@ -171,7 +170,6 @@ private MemberInfo member() { } private AuthProperties properties() { - return new AuthProperties( new AuthProperties.Jwt( "test-secret-must-be-at-least-thirty-two-bytes", diff --git a/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java b/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java index 9f4745d..3485a72 100644 --- a/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java +++ b/common/src/main/java/com/nalssilog/common/filter/RequestLoggingFilter.java @@ -94,7 +94,6 @@ protected void doFilterInternal(HttpServletRequest request, static String sanitizeQuery(String query) { if (query == null || query.isBlank()) { - return null; } @@ -115,7 +114,6 @@ private static String sanitizeQueryPart(String part) { } if (SENSITIVE_QUERY_PARAMETERS.contains(decodedName.toLowerCase(Locale.ROOT))) { - return rawName + "=***"; } @@ -133,7 +131,6 @@ private String resolveActor(HttpServletRequest request) { Object memberId = request.getAttribute(ACTOR_MEMBER_ID); if (memberId != null) { - return "member:" + memberId; } diff --git a/common/src/main/java/com/nalssilog/common/filter/TraceIdFilter.java b/common/src/main/java/com/nalssilog/common/filter/TraceIdFilter.java index 373cb64..6e0e976 100644 --- a/common/src/main/java/com/nalssilog/common/filter/TraceIdFilter.java +++ b/common/src/main/java/com/nalssilog/common/filter/TraceIdFilter.java @@ -24,32 +24,32 @@ @Order(Ordered.HIGHEST_PRECEDENCE) public class TraceIdFilter extends OncePerRequestFilter { - public static final String TRACE_ID = "traceId"; - private static final String TRACE_ID_HEADER = "X-Trace-Id"; - - @Override - protected void doFilterInternal(HttpServletRequest request, - HttpServletResponse response, - FilterChain filterChain) throws ServletException, IOException { - String traceId = resolveTraceId(request); - - MDC.put(TRACE_ID, traceId); - response.setHeader(TRACE_ID_HEADER, traceId); - - try { - filterChain.doFilter(request, response); - } finally { - MDC.remove(TRACE_ID); - } - } - - private String resolveTraceId(HttpServletRequest request) { - String inbound = request.getHeader(TRACE_ID_HEADER); - - if (StringUtils.hasText(inbound)) { - return inbound; - } - - return UUID.randomUUID().toString().substring(0, 8); - } + public static final String TRACE_ID = "traceId"; + private static final String TRACE_ID_HEADER = "X-Trace-Id"; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String traceId = resolveTraceId(request); + + MDC.put(TRACE_ID, traceId); + response.setHeader(TRACE_ID_HEADER, traceId); + + try { + filterChain.doFilter(request, response); + } finally { + MDC.remove(TRACE_ID); + } + } + + private String resolveTraceId(HttpServletRequest request) { + String inbound = request.getHeader(TRACE_ID_HEADER); + + if (StringUtils.hasText(inbound)) { + return inbound; + } + + return UUID.randomUUID().toString().substring(0, 8); + } } diff --git a/common/src/main/java/com/nalssilog/common/response/PageResponse.java b/common/src/main/java/com/nalssilog/common/response/PageResponse.java index 1fb58a5..1f25147 100644 --- a/common/src/main/java/com/nalssilog/common/response/PageResponse.java +++ b/common/src/main/java/com/nalssilog/common/response/PageResponse.java @@ -22,7 +22,6 @@ public record PageResponse( } public static PageResponse from(Page source) { - return new PageResponse<>( source.getContent(), source.getNumber(), @@ -56,7 +55,6 @@ public static PageResponse of( } public PageResponse map(Function mapper) { - return new PageResponse<>( items.stream().map(mapper).toList(), page, diff --git a/common/src/main/java/com/nalssilog/common/security/VerifiedRequestCredentials.java b/common/src/main/java/com/nalssilog/common/security/VerifiedRequestCredentials.java index 4eec01a..4d33a69 100644 --- a/common/src/main/java/com/nalssilog/common/security/VerifiedRequestCredentials.java +++ b/common/src/main/java/com/nalssilog/common/security/VerifiedRequestCredentials.java @@ -22,7 +22,6 @@ public static void markBearer(HttpServletRequest request) { } public static boolean hasBearer(HttpServletRequest request) { - return Boolean.TRUE.equals(request.getAttribute(BEARER_ATTRIBUTE)); } @@ -39,12 +38,10 @@ public static Optional guestAnonymousKey(HttpServletRequest request) { } public static boolean hasGuest(HttpServletRequest request) { - return guestAnonymousKey(request).isPresent(); } public static boolean hasNonCookieCredential(HttpServletRequest request) { - return hasBearer(request) || hasGuest(request); } } diff --git a/common/src/main/java/com/nalssilog/common/web/TrustedProxyChain.java b/common/src/main/java/com/nalssilog/common/web/TrustedProxyChain.java index aa9ce7c..c837301 100644 --- a/common/src/main/java/com/nalssilog/common/web/TrustedProxyChain.java +++ b/common/src/main/java/com/nalssilog/common/web/TrustedProxyChain.java @@ -30,14 +30,12 @@ public String resolve(HttpServletRequest request) { String peer = normalize(nativeRequest.getRemoteAddr()); if (!isTrusted(peer)) { - return peer; } String forwardedFor = nativeRequest.getHeader("X-Forwarded-For"); if (!StringUtils.hasText(forwardedFor)) { - return peer; } @@ -50,6 +48,7 @@ public String resolve(HttpServletRequest request) { if (!StringUtils.hasText(candidate)) { break; } + current = candidate; } @@ -66,6 +65,7 @@ private HttpServletRequest unwrap(HttpServletRequest request) { if (nested == current) { break; } + current = nested; } @@ -74,17 +74,15 @@ private HttpServletRequest unwrap(HttpServletRequest 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; } } @@ -94,7 +92,6 @@ private boolean isTrusted(String address) { private String normalize(String address) { if (!StringUtils.hasText(address)) { - return "unknown"; } @@ -103,11 +100,11 @@ private String normalize(String address) { 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); } } @@ -115,7 +112,6 @@ private String normalize(String address) { int colon = normalized.lastIndexOf(':'); if (colon > 0 && normalized.indexOf(':') == colon && normalized.substring(0, colon).contains(".")) { - return normalized.substring(0, colon); } diff --git a/config b/config index 00a391d..f383ce4 160000 --- a/config +++ b/config @@ -1 +1 @@ -Subproject commit 00a391d1edeb1b593e7b231ebb907d05447822a3 +Subproject commit f383ce419e3a4498fa1d5fa3c3e41c73b11dde78 diff --git a/location/src/main/java/com/nalssilog/location/api/LocationController.java b/location/src/main/java/com/nalssilog/location/api/LocationController.java index 354ce79..298ea6c 100644 --- a/location/src/main/java/com/nalssilog/location/api/LocationController.java +++ b/location/src/main/java/com/nalssilog/location/api/LocationController.java @@ -26,7 +26,6 @@ public PageResponse search( @RequestParam @NotBlank @Size(max = 50) String keyword, @RequestParam(defaultValue = "0") @Min(0) int page ) { - return locationService.search(keyword, page).map(LocationResponse::from); } @@ -35,19 +34,16 @@ public LocationResponse reverseGeocode( @RequestParam double lat, @RequestParam double lng ) { - return LocationResponse.from(locationService.reverseGeocode(lat, lng)); } @GetMapping("/popular") public PopularLocationsResponse popular() { - return PopularLocationsResponse.from(locationService.getPopular()); } @GetMapping("/{id}") public LocationResponse detail(@PathVariable Long id) { - return LocationResponse.from(locationService.getLocation(id)); } } diff --git a/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java b/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java index 2d3826c..a4de33d 100644 --- a/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java +++ b/location/src/main/java/com/nalssilog/location/api/LocationFavoriteController.java @@ -31,7 +31,6 @@ public PageResponse myFavorites( @AuthenticationPrincipal Long memberId, @RequestParam(defaultValue = "0") @Min(0) int page ) { - return locationFavoriteService.listFavorites(memberId, page).map(LocationResponse::from); } diff --git a/location/src/main/java/com/nalssilog/location/api/dto/PopularLocationsResponse.java b/location/src/main/java/com/nalssilog/location/api/dto/PopularLocationsResponse.java index 4e26cdb..527af9c 100644 --- a/location/src/main/java/com/nalssilog/location/api/dto/PopularLocationsResponse.java +++ b/location/src/main/java/com/nalssilog/location/api/dto/PopularLocationsResponse.java @@ -59,7 +59,6 @@ public record Ranking( ) { private static Ranking from(PopularLocationSnapshotInfo.Item item) { - return new Ranking( item.rank(), item.previousRank(), diff --git a/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java b/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java index 1e88e56..24e6cb1 100644 --- a/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java +++ b/location/src/main/java/com/nalssilog/location/application/LocationFavoriteService.java @@ -27,65 +27,65 @@ @Transactional(readOnly = true) public class LocationFavoriteService { - private static final int PAGE_SIZE = 5; - private static final String FAVORITE_CONSTRAINT = "uk_location_favorite_member_location"; - - private final LocationFavoriteRepository locationFavoriteRepository; - private final LocationRepository locationRepository; - - private static boolean hasConstraint( - Throwable throwable - ) { - for (Throwable cause = throwable; cause != null; cause = cause.getCause()) { - if (cause instanceof ConstraintViolationException constraintViolation) { - String constraintName = constraintViolation.getConstraintName(); - - return FAVORITE_CONSTRAINT.equalsIgnoreCase(constraintName); - } - } - - return false; - } - - @Transactional - public void addFavorite(Long memberId, Long locationId) { - locationRepository.getById(locationId); - - if (locationFavoriteRepository.existsByMemberIdAndLocationId(memberId, locationId)) { - return; - } - - try { - locationFavoriteRepository.saveAndFlush( - LocationFavorite.of(memberId, locationId)); - } catch (DataIntegrityViolationException exception) { - if (hasConstraint(exception)) { - throw new NalssiLogException( - LocationErrorCode.FAVORITE_ALREADY_EXISTS); - } - - throw exception; - } - } - - @Transactional - public void removeFavorite(Long memberId, Long locationId) { - locationFavoriteRepository.deleteByMemberIdAndLocationId(memberId, locationId); - } - - public PageResponse listFavorites(Long memberId, int page) { - Page favorites = - locationFavoriteRepository.findAllByMemberIdOrderByCreatedAtDescIdDesc( - memberId, - PageRequest.of(page, PAGE_SIZE)); - List favoriteIds = favorites.getContent().stream() - .map(LocationFavorite::getLocationId) - .toList(); - - return PageResponse.of( - locationRepository.findByIds(favoriteIds), - page, - PAGE_SIZE, - favorites.getTotalElements()); - } + private static final int PAGE_SIZE = 5; + private static final String FAVORITE_CONSTRAINT = "uk_location_favorite_member_location"; + + private final LocationFavoriteRepository locationFavoriteRepository; + private final LocationRepository locationRepository; + + private static boolean hasConstraint( + Throwable throwable + ) { + for (Throwable cause = throwable; cause != null; cause = cause.getCause()) { + if (cause instanceof ConstraintViolationException constraintViolation) { + String constraintName = constraintViolation.getConstraintName(); + + return FAVORITE_CONSTRAINT.equalsIgnoreCase(constraintName); + } + } + + return false; + } + + @Transactional + public void addFavorite(Long memberId, Long locationId) { + locationRepository.getById(locationId); + + if (locationFavoriteRepository.existsByMemberIdAndLocationId(memberId, locationId)) { + return; + } + + try { + locationFavoriteRepository.saveAndFlush( + LocationFavorite.of(memberId, locationId)); + } catch (DataIntegrityViolationException exception) { + if (hasConstraint(exception)) { + throw new NalssiLogException( + LocationErrorCode.FAVORITE_ALREADY_EXISTS); + } + + throw exception; + } + } + + @Transactional + public void removeFavorite(Long memberId, Long locationId) { + locationFavoriteRepository.deleteByMemberIdAndLocationId(memberId, locationId); + } + + public PageResponse listFavorites(Long memberId, int page) { + Page favorites = + locationFavoriteRepository.findAllByMemberIdOrderByCreatedAtDescIdDesc( + memberId, + PageRequest.of(page, PAGE_SIZE)); + List favoriteIds = favorites.getContent().stream() + .map(LocationFavorite::getLocationId) + .toList(); + + return PageResponse.of( + locationRepository.findByIds(favoriteIds), + page, + PAGE_SIZE, + favorites.getTotalElements()); + } } diff --git a/location/src/main/java/com/nalssilog/location/application/LocationService.java b/location/src/main/java/com/nalssilog/location/application/LocationService.java index e8bb4e9..f7a27cb 100644 --- a/location/src/main/java/com/nalssilog/location/application/LocationService.java +++ b/location/src/main/java/com/nalssilog/location/application/LocationService.java @@ -53,7 +53,6 @@ public PageResponse search(String keyword, int page) { } public LocationInfo getLocation(Long locationId) { - return locationRepository.getById(locationId); } @@ -61,7 +60,6 @@ public LocationInfo getLocation(Long locationId) { * 여러 지역 일괄 조회. 없는 id 는 조용히 제외한다(제보 목록의 지역 enrich 배치용). */ public List getLocations(List locationIds) { - return locationRepository.findByIds(locationIds); } @@ -103,7 +101,6 @@ private static void validateCoordinates(double latitude, double longitude) { } private static String normalizeLegacyRegionName(String keyword) { - return keyword .replace(FORMER_JEONBUK, CURRENT_JEONBUK) .replace(FORMER_JEONNAM, CURRENT_JEONNAM_GWANGJU) diff --git a/location/src/main/java/com/nalssilog/location/client/KakaoMapClient.java b/location/src/main/java/com/nalssilog/location/client/KakaoMapClient.java index fb221f8..b75ecc7 100644 --- a/location/src/main/java/com/nalssilog/location/client/KakaoMapClient.java +++ b/location/src/main/java/com/nalssilog/location/client/KakaoMapClient.java @@ -28,6 +28,7 @@ public class KakaoMapClient { @Autowired public KakaoMapClient(KakaoMapProperties properties) { SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(properties.connectTimeout()); requestFactory.setReadTimeout(properties.readTimeout()); @@ -101,6 +102,7 @@ boolean hasRequiredValues() { KakaoRegion toRegion() { String legalDongName = ri == null || ri.isBlank() ? dong : dong + " " + ri; + return new KakaoRegion( code, sido, diff --git a/location/src/main/java/com/nalssilog/location/config/KakaoMapProperties.java b/location/src/main/java/com/nalssilog/location/config/KakaoMapProperties.java index 54dc288..3db7511 100644 --- a/location/src/main/java/com/nalssilog/location/config/KakaoMapProperties.java +++ b/location/src/main/java/com/nalssilog/location/config/KakaoMapProperties.java @@ -18,9 +18,11 @@ public record KakaoMapProperties( public KakaoMapProperties { baseUrl = baseUrl == null || baseUrl.isBlank() ? DEFAULT_BASE_URL : baseUrl.strip(); + if (reverseGeocodePath == null || reverseGeocodePath.isBlank()) { throw new IllegalArgumentException("Kakao reverse-geocode-path must be configured"); } + reverseGeocodePath = reverseGeocodePath.strip(); restApiKey = restApiKey == null ? "" : restApiKey.strip(); connectTimeout = connectTimeout == null ? DEFAULT_CONNECT_TIMEOUT : connectTimeout; diff --git a/location/src/main/java/com/nalssilog/location/config/LegalDongCsvReader.java b/location/src/main/java/com/nalssilog/location/config/LegalDongCsvReader.java index 2032aca..82eba64 100644 --- a/location/src/main/java/com/nalssilog/location/config/LegalDongCsvReader.java +++ b/location/src/main/java/com/nalssilog/location/config/LegalDongCsvReader.java @@ -30,14 +30,17 @@ static List read(Resource resource) { try (BufferedReader reader = new BufferedReader( new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8))) { String header = removeBom(reader.readLine()); + if (!EXPECTED_HEADER.equals(header)) { throw new IllegalStateException("Unexpected legal-dong CSV header: " + header); } String line; int lineNumber = 1; + while ((line = reader.readLine()) != null) { lineNumber++; + if (!line.isBlank()) { sourceRows.add(parse(line, lineNumber)); } @@ -47,6 +50,7 @@ static List read(Resource resource) { } Set parentCodesWithRi = new HashSet<>(); + for (SourceRow row : sourceRows) { if (!row.ri().isBlank()) { parentCodesWithRi.add(row.adminCode().substring(0, 8) + "00"); @@ -62,6 +66,7 @@ static List read(Resource resource) { private static SourceRow parse(String line, int lineNumber) { String[] columns = line.split(",", -1); + if (columns.length != 7) { throw new IllegalStateException( "Invalid legal-dong CSV column count at line " + lineNumber); @@ -69,6 +74,7 @@ private static SourceRow parse(String line, int lineNumber) { String adminCode = columns[0].strip(); String sido = columns[1].strip(); + if (!adminCode.matches("\\d{10}") || sido.isBlank()) { throw new IllegalStateException("Invalid legal-dong CSV row at line " + lineNumber); } @@ -105,6 +111,7 @@ private static String removeBom(String value) { if (value != null && value.startsWith("\uFEFF")) { return value.substring(1); } + return value; } @@ -121,6 +128,7 @@ private record SourceRow( LegalDongRow toLegalDongRow() { String dong = ri.isBlank() ? eupMyeonDong : eupMyeonDong + " " + ri; + return new LegalDongRow(adminCode, sido, sigungu, dong); } } diff --git a/location/src/main/java/com/nalssilog/location/config/LegalDongDataInitializer.java b/location/src/main/java/com/nalssilog/location/config/LegalDongDataInitializer.java index 1dda3a3..aa94a63 100644 --- a/location/src/main/java/com/nalssilog/location/config/LegalDongDataInitializer.java +++ b/location/src/main/java/com/nalssilog/location/config/LegalDongDataInitializer.java @@ -54,6 +54,7 @@ public void run(ApplicationArguments args) { List rows = LegalDongCsvReader.read( new ClassPathResource(DATASET_RESOURCE)); + jdbcTemplate.batchUpdate(UPSERT_SQL, rows, BATCH_SIZE, this::setParameters); jdbcTemplate.update(""" insert into location_dataset_import (dataset_version, source_name, imported_at) diff --git a/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java b/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java index 0983945..ce4c6bc 100644 --- a/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java +++ b/location/src/main/java/com/nalssilog/location/config/PopularLocationProperties.java @@ -19,12 +19,15 @@ public record PopularLocationProperties( if (window == null || window.isZero() || window.isNegative()) { window = DEFAULT_WINDOW; } + if (limit < 1) { limit = DEFAULT_LIMIT; } + if (limit > MAX_LIMIT) { throw new IllegalArgumentException("popular location limit must not exceed " + MAX_LIMIT); } + if (snapshotInterval == null || snapshotInterval.isZero() || snapshotInterval.isNegative()) { snapshotInterval = DEFAULT_SNAPSHOT_INTERVAL; } diff --git a/location/src/main/java/com/nalssilog/location/domain/Location.java b/location/src/main/java/com/nalssilog/location/domain/Location.java index 6a71d7e..abdaa9c 100644 --- a/location/src/main/java/com/nalssilog/location/domain/Location.java +++ b/location/src/main/java/com/nalssilog/location/domain/Location.java @@ -49,6 +49,7 @@ public class Location extends BaseTimeEntity { public static Location of(String adminCode, String sido, String sigungu, String dong, Double latitude, Double longitude) { Location location = new Location(); + location.adminCode = adminCode; location.sido = sido; location.sigungu = sigungu; diff --git a/location/src/main/java/com/nalssilog/location/domain/LocationFavorite.java b/location/src/main/java/com/nalssilog/location/domain/LocationFavorite.java index fd9bac9..e0cd30a 100644 --- a/location/src/main/java/com/nalssilog/location/domain/LocationFavorite.java +++ b/location/src/main/java/com/nalssilog/location/domain/LocationFavorite.java @@ -37,6 +37,7 @@ public class LocationFavorite extends BaseTimeEntity { public static LocationFavorite of(Long memberId, Long locationId) { LocationFavorite favorite = new LocationFavorite(); + favorite.memberId = memberId; favorite.locationId = locationId; diff --git a/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java b/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java index 4e62e09..86d8ccb 100644 --- a/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java +++ b/location/src/main/java/com/nalssilog/location/repository/LocationRepository.java @@ -43,7 +43,6 @@ public Page searchByKeyword(String keyword, Pageable pageable) { .toList(); if (tokens.isEmpty()) { - return Page.empty(pageable); } @@ -105,7 +104,6 @@ private BooleanExpression matchesToken(StringExpression label, String token) { .or(label.startsWith(token)); if (token.length() == 1) { - return prefixMatch; } @@ -113,7 +111,6 @@ private BooleanExpression matchesToken(StringExpression label, String token) { } public LocationInfo getById(Long id) { - return locationJpaRepository.findById(id) .map(LocationInfo::of) .orElseThrow(() -> new NalssiLogException(LocationErrorCode.LOCATION_NOT_FOUND)); @@ -132,7 +129,6 @@ public List findByIds(List ids) { } public boolean isEmpty() { - return locationJpaRepository.count() == 0; } @@ -145,7 +141,6 @@ public void saveAll(List locations) { * 동시에 같은 코드가 등록되면 유니크 키가 승자를 정하고 커밋된 행을 다시 조회한다. */ public LocationInfo findOrCreate(KakaoRegion region) { - return locationJpaRepository.findByAdminCode(region.adminCode()) .map(LocationInfo::of) .orElseGet(() -> saveOrLoadConcurrent(region)); @@ -163,13 +158,11 @@ private LocationInfo saveOrLoadConcurrent(KakaoRegion region) { return LocationInfo.of(locationJpaRepository.saveAndFlush(location)); } catch (DataIntegrityViolationException exception) { - return findByAdminCodeOrThrow(region.adminCode()); } } private LocationInfo findByAdminCodeOrThrow(String adminCode) { - return locationJpaRepository.findByAdminCode(adminCode) .map(LocationInfo::of) .orElseThrow(() -> new NalssiLogException(LocationErrorCode.LOCATION_NOT_FOUND)); diff --git a/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java b/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java index b7dd785..0f12a12 100644 --- a/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java +++ b/location/src/test/java/com/nalssilog/location/application/LocationServiceTest.java @@ -190,7 +190,6 @@ void enrichesPopularSnapshotWithoutChangingItsRanking() { } private LocationInfo location(Long id, String dong) { - return new LocationInfo(id, "서울특별시", "강남구", dong, 37.5, 127.0); } } diff --git a/location/src/test/java/com/nalssilog/location/client/KakaoMapClientTest.java b/location/src/test/java/com/nalssilog/location/client/KakaoMapClientTest.java index 86528b3..69703c4 100644 --- a/location/src/test/java/com/nalssilog/location/client/KakaoMapClientTest.java +++ b/location/src/test/java/com/nalssilog/location/client/KakaoMapClientTest.java @@ -29,6 +29,7 @@ void setUp() { RestClient.Builder builder = RestClient.builder() .baseUrl("https://dapi.kakao.com") .defaultHeader(HttpHeaders.AUTHORIZATION, "KakaoAK test-rest-api-key"); + server = MockRestServiceServer.bindTo(builder).build(); client = new KakaoMapClient(builder.build(), REVERSE_GEOCODE_PATH); } diff --git a/member/src/main/java/com/nalssilog/member/api/MemberController.java b/member/src/main/java/com/nalssilog/member/api/MemberController.java index 2e77c67..b4790e4 100644 --- a/member/src/main/java/com/nalssilog/member/api/MemberController.java +++ b/member/src/main/java/com/nalssilog/member/api/MemberController.java @@ -38,7 +38,6 @@ public class MemberController { @GetMapping("/nickname/availability") public NicknameAvailabilityResponse checkNickname(@RequestParam @NotBlank String nickname) { - return new NicknameAvailabilityResponse(memberProfileService.isNicknameAvailable(nickname)); } @@ -47,13 +46,11 @@ public MemberMeResponse me( @AuthenticationPrincipal Long memberId, @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider ) { - return MemberMeResponse.from(memberProfileService.getMe(memberId), currentProvider); } @GetMapping("/{id}") public MemberPublicProfileResponse publicProfile(@PathVariable Long id) { - return MemberPublicProfileResponse.from(memberProfileService.getPublicProfile(id)); } @@ -63,7 +60,6 @@ public MemberMeResponse changeName( @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider, @Valid @RequestBody ChangeNameRequest request ) { - return MemberMeResponse.from(memberProfileService.changeName(memberId, request.name()), currentProvider); } @@ -73,7 +69,6 @@ public MemberMeResponse changeNickname( @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider, @Valid @RequestBody ChangeNicknameRequest request ) { - return MemberMeResponse.from( memberProfileService.changeNickname(memberId, request.nickname()), currentProvider); @@ -85,7 +80,6 @@ public MemberMeResponse changeAvatar( @CurrentSecurityContext(expression = "authentication.details.provider") Provider currentProvider, @Valid @RequestBody ChangeAvatarRequest request ) { - return MemberMeResponse.from( memberProfileService.changeAvatar(memberId, request.type(), request.value()), currentProvider); @@ -96,14 +90,12 @@ public AvatarPresignResponse presignAvatar( @AuthenticationPrincipal Long memberId, @Valid @RequestBody AvatarPresignRequest request ) { - return AvatarPresignResponse.from( memberProfileService.presignAvatar(memberId, request.contentType(), request.size())); } @GetMapping("/me/social-accounts") public List socialAccounts(@AuthenticationPrincipal Long memberId) { - return memberProfileService.getSocialAccounts(memberId).stream() .map(SocialAccountResponse::from) .toList(); diff --git a/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java b/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java index 122c0df..aa9deae 100644 --- a/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java +++ b/member/src/main/java/com/nalssilog/member/application/FeedbackRateLimiter.java @@ -59,7 +59,6 @@ public void check(Long memberId, String remoteAddress) { String actor(Long memberId, String remoteAddress) { if (memberId != null) { - return "member:" + memberId; } diff --git a/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java b/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java index e184024..54eed1e 100644 --- a/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java +++ b/member/src/main/java/com/nalssilog/member/application/MemberAccountService.java @@ -29,138 +29,139 @@ @Transactional(readOnly = true) public class MemberAccountService { - private static final String PROVIDER_USER_CONSTRAINT = - "uk_social_account_provider_user"; - private static final String MEMBER_PROVIDER_CONSTRAINT = - "uk_social_account_member_provider"; - - private final MemberRepository memberRepository; - private final SocialAccountRepository socialAccountRepository; - private final ApplicationEventPublisher eventPublisher; - - private static RuntimeException translateSocialLinkConflict( - DataIntegrityViolationException exception - ) { - String constraintName = constraintName(exception); - - if (PROVIDER_USER_CONSTRAINT.equalsIgnoreCase(constraintName)) { - return new NalssiLogException(MemberErrorCode.SOCIAL_ACCOUNT_IN_USE); - } - if (MEMBER_PROVIDER_CONSTRAINT.equalsIgnoreCase(constraintName)) { - return new NalssiLogException(MemberErrorCode.ACCOUNT_ALREADY_LINKED); - } - - return exception; - } - - private static String constraintName(Throwable throwable) { - for (Throwable cause = throwable; cause != null; cause = cause.getCause()) { - if (cause instanceof ConstraintViolationException constraintViolation) { - return constraintViolation.getConstraintName(); - } - } - - return null; - } - - /** 소셜 인증 결과 분기(생성·병합 안 함). 가입된 소셜=EXISTING, 이메일로 기존 회원 있으면 LINK_REQUIRED, 없으면 NEW. */ - public SocialLoginResult resolveSocialLogin(Provider provider, String providerUserId, String email) { - Optional linked = socialAccountRepository.findByProviderAndProviderUserId( - provider, - providerUserId); - - if (linked.isPresent()) { - SocialAccount account = linked.get(); - - return SocialLoginResult.existing(account.getMember().getId(), account.getMember().getStatus()); - } - - if (email == null || email.isBlank()) { - return SocialLoginResult.newMember(email); - } - - return memberRepository.findMemberInfoByEmail(email) - .map(found -> SocialLoginResult.linkRequired(found.id(), email, found.connectedProviders())) - .orElseGet(() -> SocialLoginResult.newMember(email)); - } - - /** 기존 회원에 새 소셜 계정 연동(호출 전 재인증으로 소유권 증명 전제). */ - @Transactional - public MemberInfo linkSocial( - Long targetMemberId, - Provider provider, - String providerUserId, - String email - ) { - Optional linkedAccount = - socialAccountRepository.findByProviderAndProviderUserId( - provider, - providerUserId); - - if (linkedAccount.isPresent()) { - MemberErrorCode errorCode = linkedAccount.get().getMember().getId().equals(targetMemberId) - ? MemberErrorCode.ACCOUNT_ALREADY_LINKED - : MemberErrorCode.SOCIAL_ACCOUNT_IN_USE; - - throw new NalssiLogException(errorCode); - } - - if (socialAccountRepository.findByMemberIdAndProvider(targetMemberId, provider).isPresent()) { - throw new NalssiLogException(MemberErrorCode.ACCOUNT_ALREADY_LINKED); - } - - Member member = memberRepository.getMember(targetMemberId); - - try { - socialAccountRepository.saveAndFlush( - SocialAccount.link(member, provider, providerUserId, email)); - } catch (DataIntegrityViolationException exception) { - throw translateSocialLinkConflict(exception); - } - - return memberRepository.getMemberInfo(targetMemberId); - } - - /** 최종 로그인 세션 발급에 사용한 소셜 계정의 로그인 시각만 갱신한다. */ - @Transactional - public void recordLogin(Long memberId, Provider provider) { - SocialAccount account = socialAccountRepository - .findByMemberIdAndProvider(memberId, provider) - .orElseThrow(() -> new NalssiLogException( - MemberErrorCode.SOCIAL_ACCOUNT_NOT_FOUND)); - - account.touchLogin(); - } - - /** 회원 탈퇴. 익명화 + 소셜 삭제 + 제보 익명화용 MemberWithdrawnEvent 발행. 세션·쿠키 정리는 auth. */ - @Transactional - public void withdraw(Long memberId) { - Member member = memberRepository.getMember(memberId); - - member.withdraw(); - socialAccountRepository.deleteAllByMemberId(memberId); - - eventPublisher.publishEvent(MemberWithdrawnEvent.of(memberId)); - } - - public MemberInfo getMemberInfo(Long memberId) { - return memberRepository.getMemberInfo(memberId); - } - - public Optional findMemberInfo(Long memberId) { - return memberRepository.findMemberInfo(memberId); - } - - public List findMemberSummaries(Collection memberIds) { - return memberRepository.findSummariesByIds(memberIds); - } - - public Optional findMemberInfo( - Provider provider, - String providerUserId - ) { - return socialAccountRepository - .findByProviderAndProviderUserId(provider, providerUserId) - .map(account -> memberRepository.getMemberInfo(account.getMember().getId())); - } + private static final String PROVIDER_USER_CONSTRAINT = + "uk_social_account_provider_user"; + private static final String MEMBER_PROVIDER_CONSTRAINT = + "uk_social_account_member_provider"; + + private final MemberRepository memberRepository; + private final SocialAccountRepository socialAccountRepository; + private final ApplicationEventPublisher eventPublisher; + + private static RuntimeException translateSocialLinkConflict( + DataIntegrityViolationException exception + ) { + String constraintName = constraintName(exception); + + if (PROVIDER_USER_CONSTRAINT.equalsIgnoreCase(constraintName)) { + return new NalssiLogException(MemberErrorCode.SOCIAL_ACCOUNT_IN_USE); + } + + if (MEMBER_PROVIDER_CONSTRAINT.equalsIgnoreCase(constraintName)) { + return new NalssiLogException(MemberErrorCode.ACCOUNT_ALREADY_LINKED); + } + + return exception; + } + + private static String constraintName(Throwable throwable) { + for (Throwable cause = throwable; cause != null; cause = cause.getCause()) { + if (cause instanceof ConstraintViolationException constraintViolation) { + return constraintViolation.getConstraintName(); + } + } + + return null; + } + + /** 소셜 인증 결과 분기(생성·병합 안 함). 가입된 소셜=EXISTING, 이메일로 기존 회원 있으면 LINK_REQUIRED, 없으면 NEW. */ + public SocialLoginResult resolveSocialLogin(Provider provider, String providerUserId, String email) { + Optional linked = socialAccountRepository.findByProviderAndProviderUserId( + provider, + providerUserId); + + if (linked.isPresent()) { + SocialAccount account = linked.get(); + + return SocialLoginResult.existing(account.getMember().getId(), account.getMember().getStatus()); + } + + if (email == null || email.isBlank()) { + return SocialLoginResult.newMember(email); + } + + return memberRepository.findMemberInfoByEmail(email) + .map(found -> SocialLoginResult.linkRequired(found.id(), email, found.connectedProviders())) + .orElseGet(() -> SocialLoginResult.newMember(email)); + } + + /** 기존 회원에 새 소셜 계정 연동(호출 전 재인증으로 소유권 증명 전제). */ + @Transactional + public MemberInfo linkSocial( + Long targetMemberId, + Provider provider, + String providerUserId, + String email + ) { + Optional linkedAccount = + socialAccountRepository.findByProviderAndProviderUserId( + provider, + providerUserId); + + if (linkedAccount.isPresent()) { + MemberErrorCode errorCode = linkedAccount.get().getMember().getId().equals(targetMemberId) + ? MemberErrorCode.ACCOUNT_ALREADY_LINKED + : MemberErrorCode.SOCIAL_ACCOUNT_IN_USE; + + throw new NalssiLogException(errorCode); + } + + if (socialAccountRepository.findByMemberIdAndProvider(targetMemberId, provider).isPresent()) { + throw new NalssiLogException(MemberErrorCode.ACCOUNT_ALREADY_LINKED); + } + + Member member = memberRepository.getMember(targetMemberId); + + try { + socialAccountRepository.saveAndFlush( + SocialAccount.link(member, provider, providerUserId, email)); + } catch (DataIntegrityViolationException exception) { + throw translateSocialLinkConflict(exception); + } + + return memberRepository.getMemberInfo(targetMemberId); + } + + /** 최종 로그인 세션 발급에 사용한 소셜 계정의 로그인 시각만 갱신한다. */ + @Transactional + public void recordLogin(Long memberId, Provider provider) { + SocialAccount account = socialAccountRepository + .findByMemberIdAndProvider(memberId, provider) + .orElseThrow(() -> new NalssiLogException( + MemberErrorCode.SOCIAL_ACCOUNT_NOT_FOUND)); + + account.touchLogin(); + } + + /** 회원 탈퇴. 익명화 + 소셜 삭제 + 제보 익명화용 MemberWithdrawnEvent 발행. 세션·쿠키 정리는 auth. */ + @Transactional + public void withdraw(Long memberId) { + Member member = memberRepository.getMember(memberId); + + member.withdraw(); + socialAccountRepository.deleteAllByMemberId(memberId); + + eventPublisher.publishEvent(MemberWithdrawnEvent.of(memberId)); + } + + public MemberInfo getMemberInfo(Long memberId) { + return memberRepository.getMemberInfo(memberId); + } + + public Optional findMemberInfo(Long memberId) { + return memberRepository.findMemberInfo(memberId); + } + + public List findMemberSummaries(Collection memberIds) { + return memberRepository.findSummariesByIds(memberIds); + } + + public Optional findMemberInfo( + Provider provider, + String providerUserId + ) { + return socialAccountRepository + .findByProviderAndProviderUserId(provider, providerUserId) + .map(account -> memberRepository.getMemberInfo(account.getMember().getId())); + } } diff --git a/member/src/main/java/com/nalssilog/member/application/MemberProfileService.java b/member/src/main/java/com/nalssilog/member/application/MemberProfileService.java index cba6a20..c15e220 100644 --- a/member/src/main/java/com/nalssilog/member/application/MemberProfileService.java +++ b/member/src/main/java/com/nalssilog/member/application/MemberProfileService.java @@ -35,6 +35,7 @@ public boolean isNicknameAvailable(String nickname) { @Transactional public MemberInfo changeName(Long memberId, String name) { Member member = memberRepository.getMember(memberId); + member.changeName(name.strip()); return memberRepository.getMemberInfo(memberId); diff --git a/member/src/main/java/com/nalssilog/member/application/MemberRegistrationService.java b/member/src/main/java/com/nalssilog/member/application/MemberRegistrationService.java index 58deab4..39b0ba1 100644 --- a/member/src/main/java/com/nalssilog/member/application/MemberRegistrationService.java +++ b/member/src/main/java/com/nalssilog/member/application/MemberRegistrationService.java @@ -60,6 +60,7 @@ public MemberInfo registerMember(Provider provider, String providerUserId, Strin private MemberInfo registerInTransaction(Provider provider, String providerUserId, String email, String name, String nickname, List agreedTerms) { Member member = memberRepository.saveAndFlush(Member.register(email, name, nickname)); + socialAccountRepository.save(SocialAccount.register(member, provider, providerUserId, email)); consentService.recordOnboardingConsents(member.getId(), agreedTerms); diff --git a/member/src/main/java/com/nalssilog/member/config/FeedbackRateLimitProperties.java b/member/src/main/java/com/nalssilog/member/config/FeedbackRateLimitProperties.java index ecf8d8d..ee0f1df 100644 --- a/member/src/main/java/com/nalssilog/member/config/FeedbackRateLimitProperties.java +++ b/member/src/main/java/com/nalssilog/member/config/FeedbackRateLimitProperties.java @@ -16,16 +16,17 @@ public record 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); - } + + trustedProxies = trustedProxies == null + ? List.of() + : 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 index ca07206..eb40d7b 100644 --- a/member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java +++ b/member/src/main/java/com/nalssilog/member/config/TrustedProxyClientIpResolver.java @@ -19,7 +19,6 @@ public TrustedProxyClientIpResolver(FeedbackRateLimitProperties properties) { } public String resolve(HttpServletRequest request) { - return trustedProxyChain.resolve(request); } } diff --git a/member/src/main/java/com/nalssilog/member/domain/Feedback.java b/member/src/main/java/com/nalssilog/member/domain/Feedback.java index bf60834..9ed2f04 100644 --- a/member/src/main/java/com/nalssilog/member/domain/Feedback.java +++ b/member/src/main/java/com/nalssilog/member/domain/Feedback.java @@ -35,6 +35,7 @@ public class Feedback extends BaseTimeEntity { public static Feedback create(Long authorMemberId, String content) { Feedback feedback = new Feedback(); + feedback.authorMemberId = authorMemberId; feedback.content = content; diff --git a/member/src/main/java/com/nalssilog/member/domain/Member.java b/member/src/main/java/com/nalssilog/member/domain/Member.java index 16d002b..d4ff180 100644 --- a/member/src/main/java/com/nalssilog/member/domain/Member.java +++ b/member/src/main/java/com/nalssilog/member/domain/Member.java @@ -66,6 +66,7 @@ public class Member extends BaseTimeEntity { /** 가입 확정 시 ACTIVE 회원을 처음 생성(가입 전엔 signup 티켓에만 존재). */ public static Member register(String email, String name, String nickname) { Member member = new Member(); + member.email = email; member.name = name; member.nickname = nickname; @@ -98,6 +99,7 @@ public void withdraw() { if (this.status == MemberStatus.WITHDRAWN) { throw new NalssiLogException(MemberErrorCode.ALREADY_WITHDRAWN); } + this.status = MemberStatus.WITHDRAWN; this.withdrawnAt = Instant.now(); this.email = null; diff --git a/member/src/main/java/com/nalssilog/member/domain/MemberConsent.java b/member/src/main/java/com/nalssilog/member/domain/MemberConsent.java index 3fed01b..bf03715 100644 --- a/member/src/main/java/com/nalssilog/member/domain/MemberConsent.java +++ b/member/src/main/java/com/nalssilog/member/domain/MemberConsent.java @@ -50,6 +50,7 @@ public class MemberConsent extends BaseTimeEntity { public static MemberConsent agree(Long memberId, TermsType termsType, String version) { MemberConsent consent = new MemberConsent(); + consent.memberId = memberId; consent.termsType = termsType; consent.version = version; diff --git a/member/src/main/java/com/nalssilog/member/domain/Provider.java b/member/src/main/java/com/nalssilog/member/domain/Provider.java index 202b809..d0f89f5 100644 --- a/member/src/main/java/com/nalssilog/member/domain/Provider.java +++ b/member/src/main/java/com/nalssilog/member/domain/Provider.java @@ -6,7 +6,6 @@ public enum Provider { GOOGLE, KAKAO, NAVER, APPLE; public static Provider from(String registrationId) { - return valueOf(registrationId.toUpperCase(Locale.ROOT)); } } diff --git a/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java b/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java index 50c4617..b7f83dc 100644 --- a/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java +++ b/member/src/main/java/com/nalssilog/member/domain/SocialAccount.java @@ -59,6 +59,7 @@ public class SocialAccount extends BaseTimeEntity { public static SocialAccount register(Member member, Provider provider, String providerUserId, String providerEmail) { SocialAccount account = create(member, provider, providerUserId, providerEmail); + account.lastLoginAt = Instant.now(); return account; @@ -77,6 +78,7 @@ public static SocialAccount link( private static SocialAccount create(Member member, Provider provider, String providerUserId, String providerEmail) { SocialAccount account = new SocialAccount(); + account.member = member; account.provider = provider; account.providerUserId = providerUserId; diff --git a/member/src/test/java/com/nalssilog/member/application/ConsentServiceTest.java b/member/src/test/java/com/nalssilog/member/application/ConsentServiceTest.java index 5e8632b..ffee012 100644 --- a/member/src/test/java/com/nalssilog/member/application/ConsentServiceTest.java +++ b/member/src/test/java/com/nalssilog/member/application/ConsentServiceTest.java @@ -29,6 +29,7 @@ void acceptsAndRecordsBothRequiredTerms() { @SuppressWarnings("unchecked") ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(repository).saveAll(captor.capture()); assertThat(captor.getValue()) .extracting(MemberConsent::getTermsType) diff --git a/member/src/test/java/com/nalssilog/member/application/FeedbackRateLimiterTest.java b/member/src/test/java/com/nalssilog/member/application/FeedbackRateLimiterTest.java index 3559d84..f02ea87 100644 --- a/member/src/test/java/com/nalssilog/member/application/FeedbackRateLimiterTest.java +++ b/member/src/test/java/com/nalssilog/member/application/FeedbackRateLimiterTest.java @@ -28,7 +28,7 @@ class FeedbackRateLimiterTest { 5, Duration.ofMinutes(10), "test-feedback-hmac-secret", - List.of("127.0.0.0/8"))); + List.of())); @Test void rejectsSubmissionAboveConfiguredLimit() { @@ -37,7 +37,7 @@ void rejectsSubmissionAboveConfiguredLimit() { NalssiLogException exception = catchThrowableOfType( NalssiLogException.class, - () -> limiter.check(null, "203.0.113.10")); + () -> limiter.check(null, "client-a.test")); assertThat(exception.getErrorCode()).isEqualTo(MemberErrorCode.FEEDBACK_RATE_LIMITED); } @@ -47,18 +47,18 @@ void redisFailureDoesNotBlockFeedbackSubmission() { when(redisTemplate.execute(any(RedisScript.class), anyList(), any())) .thenThrow(new RedisConnectionFailureException("test")); - assertThatCode(() -> limiter.check(null, "203.0.113.10")) + assertThatCode(() -> limiter.check(null, "client-a.test")) .doesNotThrowAnyException(); } @Test void guestActorDoesNotExposeRawAddress() { - String actor = limiter.actor(null, "203.0.113.10"); + String actor = limiter.actor(null, "client-a.test"); assertThat(actor) .startsWith("guest:") - .doesNotContain("203.0.113.10"); - assertThat(limiter.actor(7L, "203.0.113.10")).isEqualTo("member:7"); + .doesNotContain("client-a.test"); + assertThat(limiter.actor(7L, "client-a.test")).isEqualTo("member:7"); } @Test @@ -69,9 +69,9 @@ void guestFingerprintIsKeyedByHmacSecret() { 5, Duration.ofMinutes(10), "different-feedback-hmac-secret", - List.of("127.0.0.0/8"))); + List.of())); - assertThat(otherSecretLimiter.actor(null, "203.0.113.10")) - .isNotEqualTo(limiter.actor(null, "203.0.113.10")); + assertThat(otherSecretLimiter.actor(null, "client-a.test")) + .isNotEqualTo(limiter.actor(null, "client-a.test")); } } diff --git a/member/src/test/java/com/nalssilog/member/application/FeedbackServiceTest.java b/member/src/test/java/com/nalssilog/member/application/FeedbackServiceTest.java index 5501de5..741aed4 100644 --- a/member/src/test/java/com/nalssilog/member/application/FeedbackServiceTest.java +++ b/member/src/test/java/com/nalssilog/member/application/FeedbackServiceTest.java @@ -22,12 +22,14 @@ class FeedbackServiceTest { @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", " 좋아요 "); + FeedbackInfo result = service.submit(null, "client-a.test", " 좋아요 "); ArgumentCaptor captor = ArgumentCaptor.forClass(Feedback.class); - verify(rateLimiter).check(null, "203.0.113.10"); + + verify(rateLimiter).check(null, "client-a.test"); verify(repository).save(captor.capture()); assertThat(captor.getValue().getAuthorMemberId()).isNull(); assertThat(captor.getValue().getContent()).isEqualTo("좋아요"); diff --git a/member/src/test/java/com/nalssilog/member/application/MemberProfileServiceTest.java b/member/src/test/java/com/nalssilog/member/application/MemberProfileServiceTest.java index def3d4e..ec70d04 100644 --- a/member/src/test/java/com/nalssilog/member/application/MemberProfileServiceTest.java +++ b/member/src/test/java/com/nalssilog/member/application/MemberProfileServiceTest.java @@ -48,6 +48,7 @@ void changesMemberNameAndReturnsUpdatedProfile() { null, List.of() ); + when(memberRepository.getMember(1L)).thenReturn(member); when(memberRepository.getMemberInfo(1L)).thenReturn(expected); @@ -62,6 +63,7 @@ void rejectsUnlinkingCurrentLoginProvider() { Member member = Member.register("user@example.com", "이름", "인사하는감자123"); SocialAccount current = SocialAccount.register( member, Provider.KAKAO, "kakao-id", "user@example.com"); + when(socialAccountRepository.findByMemberIdAndProvider(1L, Provider.KAKAO)) .thenReturn(Optional.of(current)); @@ -79,6 +81,7 @@ void unlinksProviderThatIsNotUsedByCurrentLogin() { Member member = Member.register("user@example.com", "이름", "인사하는감자123"); SocialAccount linked = SocialAccount.link( member, Provider.NAVER, "naver-id", "user@example.com"); + when(socialAccountRepository.findByMemberIdAndProvider(1L, Provider.NAVER)) .thenReturn(Optional.of(linked)); when(socialAccountRepository.countByMemberId(1L)).thenReturn(2L); diff --git a/member/src/test/java/com/nalssilog/member/application/MemberRegistrationServiceTest.java b/member/src/test/java/com/nalssilog/member/application/MemberRegistrationServiceTest.java index 9f186a5..8d655a3 100644 --- a/member/src/test/java/com/nalssilog/member/application/MemberRegistrationServiceTest.java +++ b/member/src/test/java/com/nalssilog/member/application/MemberRegistrationServiceTest.java @@ -74,6 +74,7 @@ void retriesWholeRegistrationWithNewNicknameOnUniqueConstraintCollision() { assertThat(actual).isSameAs(expected); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(Member.class); + verify(memberRepository, times(2)).saveAndFlush(memberCaptor.capture()); assertThat(memberCaptor.getAllValues()) .extracting(Member::getNickname) @@ -88,6 +89,7 @@ void storesEmptyNameWhenSocialProviderDoesNotReturnOne() { MemberInfo expected = new MemberInfo( 1L, "인사하는감자123", "", null, AvatarType.PRESET, "avatar-01", MemberStatus.ACTIVE, Provider.NAVER, List.of(Provider.NAVER)); + when(nicknameGenerator.generate()).thenReturn("인사하는감자123"); when(memberRepository.saveAndFlush(any(Member.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -96,6 +98,7 @@ void storesEmptyNameWhenSocialProviderDoesNotReturnOne() { service.registerMember(Provider.NAVER, "provider-id", null, null, List.of()); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(Member.class); + verify(memberRepository).saveAndFlush(memberCaptor.capture()); assertThat(memberCaptor.getValue().getName()).isEmpty(); } @@ -106,6 +109,7 @@ void stripsAndLimitsSocialNameToThirtyCharacters() { MemberInfo expected = new MemberInfo( 1L, "인사하는감자123", "가".repeat(30), null, AvatarType.PRESET, "avatar-01", MemberStatus.ACTIVE, Provider.GOOGLE, List.of(Provider.GOOGLE)); + when(nicknameGenerator.generate()).thenReturn("인사하는감자123"); when(memberRepository.saveAndFlush(any(Member.class))) .thenAnswer(invocation -> invocation.getArgument(0)); @@ -114,6 +118,7 @@ void stripsAndLimitsSocialNameToThirtyCharacters() { service.registerMember(Provider.GOOGLE, "provider-id", null, longName, List.of()); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(Member.class); + verify(memberRepository).saveAndFlush(memberCaptor.capture()); assertThat(memberCaptor.getValue().getName()).isEqualTo("가".repeat(30)); } diff --git a/member/src/test/java/com/nalssilog/member/config/TrustedProxyClientIpResolverTest.java b/member/src/test/java/com/nalssilog/member/config/TrustedProxyClientIpResolverTest.java index b9e2cd3..92ab3b8 100644 --- a/member/src/test/java/com/nalssilog/member/config/TrustedProxyClientIpResolverTest.java +++ b/member/src/test/java/com/nalssilog/member/config/TrustedProxyClientIpResolverTest.java @@ -10,37 +10,60 @@ @SuppressWarnings("java:S5960") class TrustedProxyClientIpResolverTest { + private static final String UNTRUSTED_PEER = ipv4(198, 51, 100, 20); + private static final String SPOOFED_CLIENT = ipv4(1, 2, 3, 4); + private static final String TRUSTED_PEER = ipv4(172, 17, 0, 1); + private static final String TRUSTED_HOP = ipv4(172, 18, 0, 2); + private static final String CLIENT = ipv4(203, 0, 113, 10); + private static final String LOOPBACK_RANGE = cidr(ipv4(127, 0, 0, 0), 8); + private static final String PRIVATE_RANGE = cidr(ipv4(172, 16, 0, 0), 12); + 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"))); + List.of(LOOPBACK_RANGE, PRIVATE_RANGE))); @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"); + request.setRemoteAddr(UNTRUSTED_PEER); + request.addHeader("X-Forwarded-For", SPOOFED_CLIENT); + + assertThat(resolver.resolve(request)).isEqualTo(UNTRUSTED_PEER); } @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"); + request.setRemoteAddr(TRUSTED_PEER); + request.addHeader( + "X-Forwarded-For", + String.join(", ", SPOOFED_CLIENT, CLIENT)); + + assertThat(resolver.resolve(request)).isEqualTo(CLIENT); } @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"); + request.setRemoteAddr(TRUSTED_PEER); + request.addHeader( + "X-Forwarded-For", + String.join(", ", CLIENT, TRUSTED_HOP)); + + assertThat(resolver.resolve(request)).isEqualTo(CLIENT); + } + + private static String ipv4(int first, int second, int third, int fourth) { + return "%d.%d.%d.%d".formatted(first, second, third, fourth); + } + + private static String cidr(String address, int prefixLength) { + return address + "/" + prefixLength; } } diff --git a/report/src/main/java/com/nalssilog/report/api/dto/ReportResponse.java b/report/src/main/java/com/nalssilog/report/api/dto/ReportResponse.java index 260e57c..0279c08 100644 --- a/report/src/main/java/com/nalssilog/report/api/dto/ReportResponse.java +++ b/report/src/main/java/com/nalssilog/report/api/dto/ReportResponse.java @@ -87,6 +87,7 @@ private static String anonymousAuthorKey(ReportData data) { if (data.authorAnonymousKey() != null) { return data.authorAnonymousKey(); } + if (data.authorMemberId() != null) { return "member:" + data.authorMemberId(); } diff --git a/report/src/main/java/com/nalssilog/report/api/dto/WeatherStatsResponse.java b/report/src/main/java/com/nalssilog/report/api/dto/WeatherStatsResponse.java index 3cab5ef..54d4b74 100644 --- a/report/src/main/java/com/nalssilog/report/api/dto/WeatherStatsResponse.java +++ b/report/src/main/java/com/nalssilog/report/api/dto/WeatherStatsResponse.java @@ -59,6 +59,7 @@ private static > Axis axis(E[] values, Map counts) { for (E value : values) { long count = counts.getOrDefault(value, 0L); + distribution.put(value.name(), count); if (count > max) { diff --git a/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java b/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java index 514fbc9..9e8c08a 100644 --- a/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java +++ b/report/src/main/java/com/nalssilog/report/application/PopularLocationSnapshotService.java @@ -30,193 +30,193 @@ @RequiredArgsConstructor public class PopularLocationSnapshotService { - private static final String ALGORITHM_VERSION = "UNIQUE_REPORTERS_V1"; - - private final WeatherReportRepository reportRepository; - private final PopularLocationSnapshotJpaRepository snapshotRepository; - private final PopularLocationRankJpaRepository rankRepository; - private final PopularLocationSnapshotLockRepository lockRepository; - private final PopularLocationProperties properties; - - private static PopularRankMovement movement(Integer rankChange) { - if (rankChange == null) { - return PopularRankMovement.NEW; - } - - if (rankChange > 0) { - return PopularRankMovement.UP; - } - - if (rankChange < 0) { - return PopularRankMovement.DOWN; - } - - return PopularRankMovement.SAME; - } - - @Transactional - public PopularLocationSnapshotData latestOrRefreshAt(Instant now) { - PopularLocationSnapshot latest = snapshotRepository - .findFirstByOrderByCalculatedAtDescIdDesc() - .orElse(null); - - if (isFresh(latest, now)) { - return snapshotData(latest); - } - - lockRepository.acquire(); - - latest = snapshotRepository - .findFirstByOrderByCalculatedAtDescIdDesc() - .orElse(null); - - if (isFresh(latest, now)) { - return snapshotData(latest); - } - - return createSnapshot(now, latest); - } - - @Transactional - public PopularLocationSnapshotData captureAt(Instant calculatedAt) { - lockRepository.acquire(); - - PopularLocationSnapshot previous = snapshotRepository - .findFirstByOrderByCalculatedAtDescIdDesc() - .orElse(null); - Instant snapshotTime = calculationBucketStart(calculatedAt); - - if (isSameCalculation(previous, snapshotTime)) { - return snapshotData(previous); - } - - return createSnapshot(snapshotTime, previous); - } - - private PopularLocationSnapshotData createSnapshot( - Instant calculatedAt, - PopularLocationSnapshot previous - ) { - Instant snapshotTime = calculationBucketStart(calculatedAt); - Instant windowStartedAt = snapshotTime.minus(properties.window()); - List aggregates = - reportRepository.findPopularLocationAggregates( - windowStartedAt, - snapshotTime, - properties.limit()); - Map previousPositions = previousPositions(previous); - PopularLocationSnapshot snapshot = PopularLocationSnapshot.create( - snapshotTime, - windowStartedAt, - snapshotTime, - properties.limit(), - ALGORITHM_VERSION); - - snapshotRepository.saveAndFlush(snapshot); - - List ranks = new ArrayList<>(aggregates.size()); - - for (int index = 0; index < aggregates.size(); index++) { - PopularLocationAggregate aggregate = aggregates.get(index); - int position = index + 1; - Integer previousPosition = previousPositions.get(aggregate.locationId()); - Integer rankChange = previousPosition == null - ? null - : previousPosition - position; - - ranks.add(PopularLocationRank.create( - snapshot.getId(), - aggregate.locationId(), - position, - previousPosition, - rankChange, - movement(rankChange), - aggregate.uniqueReporterCount(), - aggregate.reportCount(), - aggregate.latestReportAt())); - } - - rankRepository.saveAll(ranks); - - return snapshotData(snapshot, ranks); - } - - private Map previousPositions(PopularLocationSnapshot previous) { - if (!isComparable(previous)) { - return Map.of(); - } - - return rankRepository.findAllBySnapshotIdOrderByPositionAsc(previous.getId()).stream() - .collect(Collectors.toMap( - PopularLocationRank::getLocationId, - PopularLocationRank::getPosition)); - } - - private boolean isComparable(PopularLocationSnapshot previous) { - if (previous == null - || !ALGORITHM_VERSION.equals(previous.getAlgorithmVersion()) - || previous.getRankingLimit() != properties.limit()) { - return false; - } - - Duration previousWindow = Duration.between( - previous.getWindowStartedAt(), - previous.getWindowEndedAt()); - - return previousWindow.equals(properties.window()); - } - - private boolean isFresh( - PopularLocationSnapshot snapshot, - Instant now - ) { - return isComparable(snapshot) - && !snapshot.getCalculatedAt() - .isBefore(now.minus(properties.snapshotInterval())); - } - - private boolean isSameCalculation( - PopularLocationSnapshot snapshot, - Instant calculatedAt - ) { - return isComparable(snapshot) && snapshot.getCalculatedAt().equals(calculatedAt); - } - - private Instant calculationBucketStart(Instant calculatedAt) { - long intervalMillis = Math.max(1L, properties.snapshotInterval().toMillis()); - long bucketMillis = Math.floorDiv(calculatedAt.toEpochMilli(), intervalMillis) * intervalMillis; - - return Instant.ofEpochMilli(bucketMillis); - } - - private PopularLocationSnapshotData snapshotData(PopularLocationSnapshot snapshot) { - List ranks = rankRepository.findAllBySnapshotIdOrderByPositionAsc(snapshot.getId()); - - return snapshotData(snapshot, ranks); - } - - private PopularLocationSnapshotData snapshotData( - PopularLocationSnapshot snapshot, - List ranks - ) { - List rankings = ranks.stream() - .map(rank -> new PopularLocationSnapshotData.Rank( - rank.getLocationId(), - rank.getPosition(), - rank.getPreviousPosition(), - rank.getRankChange(), - rank.getMovement(), - rank.getUniqueReporterCount(), - rank.getReportCount(), - rank.getLatestReportAt())) - .toList(); - - return new PopularLocationSnapshotData( - snapshot.getId(), - snapshot.getCalculatedAt(), - snapshot.getWindowStartedAt(), - snapshot.getWindowEndedAt(), - snapshot.getAlgorithmVersion(), - rankings); - } + private static final String ALGORITHM_VERSION = "UNIQUE_REPORTERS_V1"; + + private final WeatherReportRepository reportRepository; + private final PopularLocationSnapshotJpaRepository snapshotRepository; + private final PopularLocationRankJpaRepository rankRepository; + private final PopularLocationSnapshotLockRepository lockRepository; + private final PopularLocationProperties properties; + + private static PopularRankMovement movement(Integer rankChange) { + if (rankChange == null) { + return PopularRankMovement.NEW; + } + + if (rankChange > 0) { + return PopularRankMovement.UP; + } + + if (rankChange < 0) { + return PopularRankMovement.DOWN; + } + + return PopularRankMovement.SAME; + } + + @Transactional + public PopularLocationSnapshotData latestOrRefreshAt(Instant now) { + PopularLocationSnapshot latest = snapshotRepository + .findFirstByOrderByCalculatedAtDescIdDesc() + .orElse(null); + + if (isFresh(latest, now)) { + return snapshotData(latest); + } + + lockRepository.acquire(); + + latest = snapshotRepository + .findFirstByOrderByCalculatedAtDescIdDesc() + .orElse(null); + + if (isFresh(latest, now)) { + return snapshotData(latest); + } + + return createSnapshot(now, latest); + } + + @Transactional + public PopularLocationSnapshotData captureAt(Instant calculatedAt) { + lockRepository.acquire(); + + PopularLocationSnapshot previous = snapshotRepository + .findFirstByOrderByCalculatedAtDescIdDesc() + .orElse(null); + Instant snapshotTime = calculationBucketStart(calculatedAt); + + if (isSameCalculation(previous, snapshotTime)) { + return snapshotData(previous); + } + + return createSnapshot(snapshotTime, previous); + } + + private PopularLocationSnapshotData createSnapshot( + Instant calculatedAt, + PopularLocationSnapshot previous + ) { + Instant snapshotTime = calculationBucketStart(calculatedAt); + Instant windowStartedAt = snapshotTime.minus(properties.window()); + List aggregates = + reportRepository.findPopularLocationAggregates( + windowStartedAt, + snapshotTime, + properties.limit()); + Map previousPositions = previousPositions(previous); + PopularLocationSnapshot snapshot = PopularLocationSnapshot.create( + snapshotTime, + windowStartedAt, + snapshotTime, + properties.limit(), + ALGORITHM_VERSION); + + snapshotRepository.saveAndFlush(snapshot); + + List ranks = new ArrayList<>(aggregates.size()); + + for (int index = 0; index < aggregates.size(); index++) { + PopularLocationAggregate aggregate = aggregates.get(index); + int position = index + 1; + Integer previousPosition = previousPositions.get(aggregate.locationId()); + Integer rankChange = previousPosition == null + ? null + : previousPosition - position; + + ranks.add(PopularLocationRank.create( + snapshot.getId(), + aggregate.locationId(), + position, + previousPosition, + rankChange, + movement(rankChange), + aggregate.uniqueReporterCount(), + aggregate.reportCount(), + aggregate.latestReportAt())); + } + + rankRepository.saveAll(ranks); + + return snapshotData(snapshot, ranks); + } + + private Map previousPositions(PopularLocationSnapshot previous) { + if (!isComparable(previous)) { + return Map.of(); + } + + return rankRepository.findAllBySnapshotIdOrderByPositionAsc(previous.getId()).stream() + .collect(Collectors.toMap( + PopularLocationRank::getLocationId, + PopularLocationRank::getPosition)); + } + + private boolean isComparable(PopularLocationSnapshot previous) { + if (previous == null + || !ALGORITHM_VERSION.equals(previous.getAlgorithmVersion()) + || previous.getRankingLimit() != properties.limit()) { + return false; + } + + Duration previousWindow = Duration.between( + previous.getWindowStartedAt(), + previous.getWindowEndedAt()); + + return previousWindow.equals(properties.window()); + } + + private boolean isFresh( + PopularLocationSnapshot snapshot, + Instant now + ) { + return isComparable(snapshot) + && !snapshot.getCalculatedAt() + .isBefore(now.minus(properties.snapshotInterval())); + } + + private boolean isSameCalculation( + PopularLocationSnapshot snapshot, + Instant calculatedAt + ) { + return isComparable(snapshot) && snapshot.getCalculatedAt().equals(calculatedAt); + } + + private Instant calculationBucketStart(Instant calculatedAt) { + long intervalMillis = Math.max(1L, properties.snapshotInterval().toMillis()); + long bucketMillis = Math.floorDiv(calculatedAt.toEpochMilli(), intervalMillis) * intervalMillis; + + return Instant.ofEpochMilli(bucketMillis); + } + + private PopularLocationSnapshotData snapshotData(PopularLocationSnapshot snapshot) { + List ranks = rankRepository.findAllBySnapshotIdOrderByPositionAsc(snapshot.getId()); + + return snapshotData(snapshot, ranks); + } + + private PopularLocationSnapshotData snapshotData( + PopularLocationSnapshot snapshot, + List ranks + ) { + List rankings = ranks.stream() + .map(rank -> new PopularLocationSnapshotData.Rank( + rank.getLocationId(), + rank.getPosition(), + rank.getPreviousPosition(), + rank.getRankChange(), + rank.getMovement(), + rank.getUniqueReporterCount(), + rank.getReportCount(), + rank.getLatestReportAt())) + .toList(); + + return new PopularLocationSnapshotData( + snapshot.getId(), + snapshot.getCalculatedAt(), + snapshot.getWindowStartedAt(), + snapshot.getWindowEndedAt(), + snapshot.getAlgorithmVersion(), + rankings); + } } diff --git a/report/src/main/java/com/nalssilog/report/application/ReportPopularLocationSource.java b/report/src/main/java/com/nalssilog/report/application/ReportPopularLocationSource.java index 9321afd..03ddaaf 100644 --- a/report/src/main/java/com/nalssilog/report/application/ReportPopularLocationSource.java +++ b/report/src/main/java/com/nalssilog/report/application/ReportPopularLocationSource.java @@ -17,7 +17,6 @@ public class ReportPopularLocationSource implements PopularLocationSource { @Override public PopularLocationSnapshotData latestSnapshot() { - return snapshotService.latestOrRefreshAt(Instant.now()); } } diff --git a/report/src/main/java/com/nalssilog/report/application/ReportService.java b/report/src/main/java/com/nalssilog/report/application/ReportService.java index efd2769..06f3a86 100644 --- a/report/src/main/java/com/nalssilog/report/application/ReportService.java +++ b/report/src/main/java/com/nalssilog/report/application/ReportService.java @@ -50,6 +50,7 @@ public class ReportService { @Transactional public ReportResponse create(ReportActor actor, CreateReportCommand command) { LocationSummary location = locationClient.getLocation(command.locationId()); + imageStorageClient.validateImageCount(command.imageKeys().size()); command.imageKeys().forEach(imageStorageClient::validateKey); command.imageKeys().forEach(imageStorageClient::verifyUploaded); @@ -59,6 +60,7 @@ public ReportResponse create(ReportActor actor, CreateReportCommand command) { command.temperature(), command.precipitation(), command.sunlight(), command.comment()) : WeatherReport.ofAnonymous(command.locationId(), actor.anonymousKey(), command.temperature(), command.precipitation(), command.sunlight(), command.comment()); + report.addImages(command.imageKeys()); ReportData data = reportRepository.save(report); diff --git a/report/src/main/java/com/nalssilog/report/config/ReportActorResolver.java b/report/src/main/java/com/nalssilog/report/config/ReportActorResolver.java index 172b5c6..099f73b 100644 --- a/report/src/main/java/com/nalssilog/report/config/ReportActorResolver.java +++ b/report/src/main/java/com/nalssilog/report/config/ReportActorResolver.java @@ -21,14 +21,12 @@ public class ReportActorResolver { public ReportActor resolveForWrite(Long memberId, HttpServletRequest request, HttpServletResponse response) { if (memberId != null) { - return ReportActor.member(memberId); } var mobileGuest = VerifiedRequestCredentials.guestAnonymousKey(request); if (mobileGuest.isPresent()) { - return ReportActor.anonymous(mobileGuest.get()); } @@ -37,14 +35,12 @@ public ReportActor resolveForWrite(Long memberId, HttpServletRequest request, Ht public ReportActor resolveForRead(Long memberId, HttpServletRequest request) { if (memberId != null) { - return ReportActor.member(memberId); } var mobileGuest = VerifiedRequestCredentials.guestAnonymousKey(request); if (mobileGuest.isPresent()) { - return ReportActor.anonymous(mobileGuest.get()); } @@ -63,6 +59,7 @@ public List resolveForOwnership(Long memberId, HttpServletRequest r if (memberId != null) { actors.add(ReportActor.member(memberId)); } + VerifiedRequestCredentials.guestAnonymousKey(request) .map(ReportActor::anonymous) .ifPresent(actors::add); diff --git a/report/src/main/java/com/nalssilog/report/domain/Thanks.java b/report/src/main/java/com/nalssilog/report/domain/Thanks.java index 4aba313..1907832 100644 --- a/report/src/main/java/com/nalssilog/report/domain/Thanks.java +++ b/report/src/main/java/com/nalssilog/report/domain/Thanks.java @@ -44,6 +44,7 @@ public class Thanks extends BaseTimeEntity { public static Thanks create(Long reportId, ActorType actorType, String actorKey) { Thanks thanks = new Thanks(); + thanks.reportId = reportId; thanks.actorType = actorType; thanks.actorKey = actorKey; diff --git a/report/src/main/java/com/nalssilog/report/domain/WeatherReport.java b/report/src/main/java/com/nalssilog/report/domain/WeatherReport.java index 3d41767..4667a16 100644 --- a/report/src/main/java/com/nalssilog/report/domain/WeatherReport.java +++ b/report/src/main/java/com/nalssilog/report/domain/WeatherReport.java @@ -67,6 +67,7 @@ public class WeatherReport extends BaseTimeEntity { public static WeatherReport ofMember(Long locationId, Long memberId, Temperature temperature, Precipitation precipitation, Sunlight sunlight, String comment) { WeatherReport report = base(locationId, temperature, precipitation, sunlight, comment); + report.authorType = ActorType.MEMBER; report.authorMemberId = memberId; @@ -76,6 +77,7 @@ public static WeatherReport ofMember(Long locationId, Long memberId, Temperature public static WeatherReport ofAnonymous(Long locationId, String anonymousKey, Temperature temperature, Precipitation precipitation, Sunlight sunlight, String comment) { WeatherReport report = base(locationId, temperature, precipitation, sunlight, comment); + report.authorType = ActorType.ANONYMOUS; report.authorAnonymousKey = anonymousKey; @@ -91,6 +93,7 @@ public void addImages(List storageKeys) { private static WeatherReport base(Long locationId, Temperature temperature, Precipitation precipitation, Sunlight sunlight, String comment) { WeatherReport report = new WeatherReport(); + report.locationId = locationId; report.temperature = temperature; report.precipitation = precipitation; diff --git a/report/src/main/java/com/nalssilog/report/domain/WeatherReportImage.java b/report/src/main/java/com/nalssilog/report/domain/WeatherReportImage.java index 07e2488..0592d64 100644 --- a/report/src/main/java/com/nalssilog/report/domain/WeatherReportImage.java +++ b/report/src/main/java/com/nalssilog/report/domain/WeatherReportImage.java @@ -42,6 +42,7 @@ public class WeatherReportImage extends BaseTimeEntity { public static WeatherReportImage create(WeatherReport report, String storageKey, int displayOrder) { WeatherReportImage image = new WeatherReportImage(); + image.report = report; image.storageKey = storageKey; image.displayOrder = displayOrder; diff --git a/report/src/test/java/com/nalssilog/report/application/ReportImageCleanupListenerTest.java b/report/src/test/java/com/nalssilog/report/application/ReportImageCleanupListenerTest.java index 292811e..7a68a66 100644 --- a/report/src/test/java/com/nalssilog/report/application/ReportImageCleanupListenerTest.java +++ b/report/src/test/java/com/nalssilog/report/application/ReportImageCleanupListenerTest.java @@ -15,6 +15,7 @@ class ReportImageCleanupListenerTest { void continuesCleanupWhenDeletingOneImageFails() { ImageStorageClient imageStorageClient = mock(ImageStorageClient.class); ReportImageCleanupListener listener = new ReportImageCleanupListener(imageStorageClient); + doThrow(new IllegalStateException("R2 unavailable")) .when(imageStorageClient).delete("reports/one.jpg"); diff --git a/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java b/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java index 5e48cf3..bb7fd3e 100644 --- a/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java +++ b/report/src/test/java/com/nalssilog/report/application/ReportServiceTest.java @@ -66,6 +66,7 @@ void setUp() { @Test void memberAuthorDeletesReportAndRelatedData() { WeatherReport report = memberReport(1L); + report.addImages(List.of("reports/2026/07/one.jpg", "reports/2026/07/two.jpg")); when(reportRepository.getReportEntity(10L)).thenReturn(report); @@ -75,6 +76,7 @@ void memberAuthorDeletesReportAndRelatedData() { verify(reportRepository).delete(report); ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(ReportDeletedEvent.class); + verify(eventPublisher).publishEvent(eventCaptor.capture()); assertThat(eventCaptor.getValue().imageKeys()) .containsExactly("reports/2026/07/one.jpg", "reports/2026/07/two.jpg"); @@ -83,6 +85,7 @@ void memberAuthorDeletesReportAndRelatedData() { @Test void anonymousAuthorCanDeleteWithExistingAnonymousCookieIdentity() { WeatherReport report = anonymousReport("anonymous-key"); + when(reportRepository.getReportEntity(10L)).thenReturn(report); service.delete(10L, List.of( @@ -97,6 +100,7 @@ void anonymousAuthorCanDeleteWithExistingAnonymousCookieIdentity() { void nonAuthorCannotDeleteReport() { WeatherReport report = memberReport(1L); List actors = List.of(ReportActor.member(2L)); + when(reportRepository.getReportEntity(10L)).thenReturn(report); NalssiLogException exception = catchThrowableOfType( @@ -113,6 +117,7 @@ void nonAuthorCannotDeleteReport() { @Test void detailMarksPreLoginAnonymousReportAsMineAfterLogin() { ReportData data = anonymousData("anonymous-key"); + when(reportRepository.getReport(10L)).thenReturn(data); when(locationClient.getLocation(1L)).thenReturn(location()); @@ -128,6 +133,7 @@ void detailMarksPreLoginAnonymousReportAsMineAfterLogin() { @Test void listIncludesOwnershipCalculatedFromAllAvailableActors() { ReportData data = anonymousData("anonymous-key"); + when(reportRepository.findPage(eq(1L), isNull(), isNull(), eq(21))) .thenReturn(List.of(data)); when(locationClient.getLocation(1L)).thenReturn(location()); @@ -177,6 +183,7 @@ void listLoadsDistinctMemberAuthorsInOneBulkCall() { @Test void statsAggregatesReportsFromTheLastThreeHours() { WeatherStatsData stats = new WeatherStatsData(0L, Map.of(), Map.of(), Map.of()); + when(locationClient.getLocation(1L)).thenReturn(location()); when(reportRepository.statsSince(eq(1L), any(Instant.class))) .thenReturn(stats); @@ -185,6 +192,7 @@ void statsAggregatesReportsFromTheLastThreeHours() { service.stats(1L); ArgumentCaptor sinceCaptor = ArgumentCaptor.forClass(Instant.class); + verify(reportRepository).statsSince(eq(1L), sinceCaptor.capture()); assertThat(sinceCaptor.getValue()) .isBetween(lowerBound, Instant.now().minusSeconds(3 * 60 * 60)); diff --git a/storage/src/main/java/com/nalssilog/storage/StorageConfig.java b/storage/src/main/java/com/nalssilog/storage/StorageConfig.java index b5a0cd2..b4411e1 100644 --- a/storage/src/main/java/com/nalssilog/storage/StorageConfig.java +++ b/storage/src/main/java/com/nalssilog/storage/StorageConfig.java @@ -48,7 +48,6 @@ public S3Client s3Client(StorageProperties properties) { } private StaticCredentialsProvider credentials(StorageProperties.R2 r2) { - return StaticCredentialsProvider.create(AwsBasicCredentials.create(r2.accessKey(), r2.secretKey())); } } diff --git a/storage/src/main/java/com/nalssilog/storage/StorageProperties.java b/storage/src/main/java/com/nalssilog/storage/StorageProperties.java index 87aef9d..29ac9fc 100644 --- a/storage/src/main/java/com/nalssilog/storage/StorageProperties.java +++ b/storage/src/main/java/com/nalssilog/storage/StorageProperties.java @@ -29,7 +29,6 @@ public record R2( ) { public String effectivePresignEndpoint() { - return presignEndpoint == null || presignEndpoint.isBlank() ? endpoint : presignEndpoint;