From 43bf0937c91a463531c184e04170f0c3042a0eee Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Fri, 24 Jul 2026 12:07:56 +0900 Subject: [PATCH 01/51] =?UTF-8?q?feat:=20=EB=A0=88=ED=8F=AC=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/auth/dto/OAuthUserInfo.java | 9 +++++++++ .../auth/repository/SocialAuthRepository.java | 15 +++++++++++++++ .../mr/domain/user/repository/UserRepository.java | 7 +++++++ 3 files changed, 31 insertions(+) create mode 100644 src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java create mode 100644 src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java create mode 100644 src/main/java/com/mr/domain/user/repository/UserRepository.java diff --git a/src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java b/src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java new file mode 100644 index 00000000..a198017b --- /dev/null +++ b/src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java @@ -0,0 +1,9 @@ +package com.mr.domain.auth.dto; + +import lombok.Builder; + +@Builder +public record OAuthUserInfo( + String socialId, + String profileImgUrl +) {} diff --git a/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java b/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java new file mode 100644 index 00000000..320b9ada --- /dev/null +++ b/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java @@ -0,0 +1,15 @@ +package com.mr.domain.auth.repository; + +import com.mr.domain.auth.entity.SocialAuth; +import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface SocialAuthRepository extends JpaRepository { + + Optional findByUserId(Long userId); + Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId); + +} \ No newline at end of file diff --git a/src/main/java/com/mr/domain/user/repository/UserRepository.java b/src/main/java/com/mr/domain/user/repository/UserRepository.java new file mode 100644 index 00000000..7cdf2815 --- /dev/null +++ b/src/main/java/com/mr/domain/user/repository/UserRepository.java @@ -0,0 +1,7 @@ +package com.mr.domain.user.repository; + +import com.mr.domain.user.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface UserRepository extends JpaRepository { +} \ No newline at end of file From 670d712473ada1d7ed77f5907d9cd12089284d18 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Fri, 24 Jul 2026 12:08:24 +0900 Subject: [PATCH 02/51] =?UTF-8?q?feat:=20Oauth=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/OAuthClientService.java | 68 +++++++++++++++++++ 1 file changed, 68 insertions(+) create mode 100644 src/main/java/com/mr/domain/auth/service/OAuthClientService.java diff --git a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java new file mode 100644 index 00000000..a899af9a --- /dev/null +++ b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java @@ -0,0 +1,68 @@ +package com.mr.domain.auth.service; + +import com.mr.domain.auth.dto.OAuthUserInfo; +import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.global.apipayload.code.CommonStatus; +import com.mr.global.apipayload.exception.GeneralException; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; + +import java.util.Map; + +@Service +public class OAuthClientService { + + private final RestClient restClient = RestClient.create(); + + public OAuthUserInfo getUserInfo(SocialType socialType, String accessToken) { + return switch (socialType) { + case KAKAO -> getKakaoUserInfo(accessToken); + case GOOGLE -> getGoogleUserInfo(accessToken); + }; + } + + private OAuthUserInfo getKakaoUserInfo(String accessToken) { + try { + Map response = restClient.get() + .uri("https://kapi.kakao.com/v2/user/me") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .body(Map.class); + + String socialId = String.valueOf(response.get("id")); + Map kakaoAccount = (Map) response.get("kakao_account"); + + Map profile = kakaoAccount != null ? (Map) kakaoAccount.get("profile") : null; + String profileImgUrl = profile != null ? (String) profile.get("profile_image_url") : null; + + return OAuthUserInfo.builder() + .socialId(socialId) + .profileImgUrl(profileImgUrl) + .build(); + } catch (Exception e) { + throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); + } + } + + private OAuthUserInfo getGoogleUserInfo(String accessToken) { + try { + Map response = restClient.get() + .uri("https://www.googleapis.com/oauth2/v2/userinfo") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .body(Map.class); + + String socialId = (String) response.get("id"); + String email = (String) response.get("email"); + String profileImgUrl = (String) response.get("picture"); + + return OAuthUserInfo.builder() + .socialId(socialId) + .profileImgUrl(profileImgUrl) + .build(); + } catch (Exception e) { + throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); + } + } +} \ No newline at end of file From 838133b0d36feef41640e7d0e323d7a4ab33018f Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Mon, 27 Jul 2026 21:52:13 +0900 Subject: [PATCH 03/51] =?UTF-8?q?feat:=20auth=20operation=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/controller/AuthController.java b/src/main/java/com/mr/domain/auth/controller/AuthController.java index abd11175..dd2efe81 100644 --- a/src/main/java/com/mr/domain/auth/controller/AuthController.java +++ b/src/main/java/com/mr/domain/auth/controller/AuthController.java @@ -1,35 +1,46 @@ package com.mr.domain.auth.controller; -import com.mr.domain.auth.dto.AuthRequestDTO; -import com.mr.domain.auth.dto.AuthResponseDTO; +import com.mr.domain.auth.dto.req.AuthRequestDTO; +import com.mr.domain.auth.dto.res.AuthResponseDTO; import com.mr.domain.auth.entity.enums.SocialType; import com.mr.domain.auth.service.AuthService; import com.mr.global.apipayload.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.security.SecurityRequirements; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.context.annotation.Profile; +import org.springframework.http.HttpHeaders; 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.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +@Tag(name = "Auth API", description = "인증 및 소셜 로그인 관련 API") @RestController @RequiredArgsConstructor @RequestMapping("/api/auth") -@Profile({"local", "dev"}) public class AuthController { private final AuthService authService; @SecurityRequirements + @Operation( + summary = "소셜 로그인 / 회원가입 API", + description = "카카오 및 구글 OAuth Access Token을 받아 로그인을 진행하고, 서비스 전용 JWT 토큰을 발급합니다." + ) @PostMapping("/login/{socialType}") public ApiResponse socialLogin( + @Parameter(description = "소셜 로그인 제공자 (KAKAO, GOOGLE)", example = "KAKAO") @PathVariable(name = "socialType") SocialType socialType, - @RequestBody @Valid AuthRequestDTO.SocialLoginRequest request + @RequestBody @Valid AuthRequestDTO.SocialLoginRequest request, + @RequestHeader(value = HttpHeaders.USER_AGENT, required = false, defaultValue = "Unknown Device") String deviceInfo ) { - AuthResponseDTO.LoginResponse response = authService.socialLogin(socialType, request.accessToken()); + AuthResponseDTO.LoginResponse response = authService.socialLogin(socialType, request.accessToken(), deviceInfo); return ApiResponse.onSuccess(response); } } \ No newline at end of file From 4a2f0e32fb84e4ea5dadd6ecd1d9eaf02be44fcd Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Mon, 27 Jul 2026 21:52:30 +0900 Subject: [PATCH 04/51] =?UTF-8?q?feat:=20DTO=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=84=EC=B9=98=20=EB=B3=80=EA=B2=BD(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mr/domain/auth/dto/{ => req}/AuthRequestDTO.java | 2 +- .../java/com/mr/domain/auth/dto/{ => res}/AuthResponseDTO.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) rename src/main/java/com/mr/domain/auth/dto/{ => req}/AuthRequestDTO.java (91%) rename src/main/java/com/mr/domain/auth/dto/{ => res}/AuthResponseDTO.java (91%) diff --git a/src/main/java/com/mr/domain/auth/dto/AuthRequestDTO.java b/src/main/java/com/mr/domain/auth/dto/req/AuthRequestDTO.java similarity index 91% rename from src/main/java/com/mr/domain/auth/dto/AuthRequestDTO.java rename to src/main/java/com/mr/domain/auth/dto/req/AuthRequestDTO.java index 57a5ad49..573ab64f 100644 --- a/src/main/java/com/mr/domain/auth/dto/AuthRequestDTO.java +++ b/src/main/java/com/mr/domain/auth/dto/req/AuthRequestDTO.java @@ -1,4 +1,4 @@ -package com.mr.domain.auth.dto; +package com.mr.domain.auth.dto.req; import jakarta.validation.constraints.NotBlank; diff --git a/src/main/java/com/mr/domain/auth/dto/AuthResponseDTO.java b/src/main/java/com/mr/domain/auth/dto/res/AuthResponseDTO.java similarity index 91% rename from src/main/java/com/mr/domain/auth/dto/AuthResponseDTO.java rename to src/main/java/com/mr/domain/auth/dto/res/AuthResponseDTO.java index dc542d7d..7c9c1d0a 100644 --- a/src/main/java/com/mr/domain/auth/dto/AuthResponseDTO.java +++ b/src/main/java/com/mr/domain/auth/dto/res/AuthResponseDTO.java @@ -1,4 +1,4 @@ -package com.mr.domain.auth.dto; +package com.mr.domain.auth.dto.res; import lombok.Builder; From 90241bb6a6adf1fdb0adb4337693d3b9513855bf Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Mon, 27 Jul 2026 21:52:43 +0900 Subject: [PATCH 05/51] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8=EC=9D=B8=20=EC=97=B0=EB=8F=99(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/auth/service/AuthService.java | 78 ++++++++++++++----- 1 file changed, 60 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 4ccac698..c79fb59a 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -1,46 +1,88 @@ package com.mr.domain.auth.service; -import com.mr.domain.auth.dto.AuthResponseDTO; +import com.mr.domain.auth.dto.OAuthUserInfo; +import com.mr.domain.auth.dto.res.AuthResponseDTO; +import com.mr.domain.auth.entity.SocialAuth; import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.domain.auth.repository.SocialAuthRepository; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.repository.UserRepository; import com.mr.global.security.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class AuthService { + private final OAuthClientService oAuthClientService; + private final UserRepository userRepository; + private final SocialAuthRepository socialAuthRepository; private final JwtTokenProvider jwtTokenProvider; - // private final KakaoOAuthService kakaoOAuthService; (외부 API 파싱용 서비스) - // private final GoogleOAuthService googleOAuthService; @Transactional - public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken) { - // 1. 외부 소셜 API (카카오/구글) 통신하여 유저 프로필(email, socialId) 파싱 - // SocialUserInfo userInfo = getSocialUserInfo(socialType, accessToken); - - // 2. TODO: User 엔티티 연동 및 가입여부 검증 (Stub 구조) - // 만약 가입 안 되어있으면 DB User 생성 -> 저장 - Long mockUserId = 1L; - String mockEmail = "user@example.com"; - String mockNickname = "뮤즈유저"; + public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String oAuthAccessToken, String deviceInfo) { + // 1. 소셜 프로필 조회 + OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, oAuthAccessToken); + + // 2. SocialAuth 존재 여부 확인 및 회원가입/로그인 진행 + // [수정] OAuthUserInfo.getSocialId() -> userInfo.getSocialId() + SocialAuth socialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.getSocialId()) + .orElse(null); + boolean isNewUser = false; + User user; + + if (socialAuth == null) { + // 신규 유저 생성 (OAuth 가입 단계) + user = userRepository.save(User.createFromOAuth(userInfo.getProfileImgUrl())); + isNewUser = true; - String appAccessToken = jwtTokenProvider.createAccessToken(mockUserId); - String appRefreshToken = jwtTokenProvider.createRefreshToken(mockUserId); + // Refresh Token 생성 및 저장 + String refreshToken = jwtTokenProvider.createRefreshToken(user.getUserId()); + String refreshTokenHash = jwtTokenProvider.hashToken(refreshToken); + LocalDateTime expiredAt = jwtTokenProvider.getRefreshTokenExpiryTime(); + // SocialAuth 정보 신규 적재 + socialAuth = SocialAuth.create( + user, + socialType, + userInfo.getSocialId(), + refreshToken, + refreshTokenHash, + expiredAt, + deviceInfo + ); + socialAuthRepository.save(socialAuth); + } else { + // 기존 유저 로그인 -> Refresh Token 갱신 + user = socialAuth.getUser(); + + String refreshToken = jwtTokenProvider.createRefreshToken(user.getUserId()); + String refreshTokenHash = jwtTokenProvider.hashToken(refreshToken); + LocalDateTime newExpiredAt = jwtTokenProvider.getRefreshTokenExpiryTime(); + + socialAuth.updateRefreshToken(refreshToken, refreshTokenHash, newExpiredAt, deviceInfo); + } + + // 3. 서비스 전용 AccessToken 생성 및 응답 반환 + String appAccessToken = jwtTokenProvider.createAccessToken(user.getUserId()); AuthResponseDTO.TokenResponse tokenResponse = AuthResponseDTO.TokenResponse.builder() .accessToken(appAccessToken) - .refreshToken(appRefreshToken) - .accessTokenExpiresInSeconds(3600L) + .refreshToken(socialAuth.getRefreshToken()) + .accessTokenExpiresInSeconds(jwtTokenProvider.getAccessTokenExpirationSeconds()) .build(); + return AuthResponseDTO.LoginResponse.builder() - .userId(mockUserId) - .nickname(mockNickname) + .userId(user.getUserId()) + .nickname(user.getNickname()) .isNewUser(isNewUser) + .isOnboardingCompleted(user.isOnboardingCompleted()) .tokenInfo(tokenResponse) .build(); } From f09ca1f8ad4f5532cf88fbdffd93e947cea83ddc Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Mon, 27 Jul 2026 21:53:17 +0900 Subject: [PATCH 06/51] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=20=EC=BB=A8?= =?UTF-8?q?=EB=B0=B4=EC=85=98=20=EB=A7=9E=EC=B6=B0=20=EC=88=98=EC=A0=95(#4?= =?UTF-8?q?2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/auth/entity/enums/SocialType.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java b/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java index 668ecdd2..bb236510 100644 --- a/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java +++ b/src/main/java/com/mr/domain/auth/entity/enums/SocialType.java @@ -1,6 +1,6 @@ package com.mr.domain.auth.entity.enums; public enum SocialType { - kakao, - google + KAKAO, + GOOGLE } From b7a362a4780b4a333c786efe3fdd213d3d1f9e39 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Mon, 27 Jul 2026 21:53:29 +0900 Subject: [PATCH 07/51] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java b/src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java index a198017b..25a74adc 100644 --- a/src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java +++ b/src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java @@ -4,6 +4,6 @@ @Builder public record OAuthUserInfo( - String socialId, + Long socialId, String profileImgUrl ) {} From 40b4527e29c7aeeb3d076e57a7be34b329098ffe Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Mon, 27 Jul 2026 21:53:36 +0900 Subject: [PATCH 08/51] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/auth/entity/SocialAuth.java | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index db7a8d65..5069f1f2 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -1,7 +1,8 @@ package com.mr.domain.auth.entity; -import com.mr.domain.auth.exception.AuthErrorStatus; import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.domain.auth.exception.AuthErrorStatus; +import com.mr.domain.user.entity.User; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.entity.BaseCreatedEntity; import jakarta.persistence.*; @@ -14,7 +15,6 @@ @Getter @Entity -// TODO: 추후 User 도메인 완성 시 인덱스 추가 @Table( name = "social_auth", uniqueConstraints = { @@ -29,9 +29,9 @@ public class SocialAuth extends BaseCreatedEntity { @Column(name = "social_auth_id") private Long id; - // TODO: User연결 예정 - @Column(name = "user_id", nullable = false) - private Long userId; + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; @Enumerated(EnumType.STRING) @Column(name = "social_type", nullable = false, length = 20) @@ -53,14 +53,14 @@ public class SocialAuth extends BaseCreatedEntity { private String deviceInfo; @Builder(access = AccessLevel.PRIVATE) - private SocialAuth(Long userId, SocialType socialType, String socialId, String refreshToken, + private SocialAuth(User user, SocialType socialType, String socialId, String refreshToken, String refreshTokenHash, LocalDateTime expiredAt, String deviceInfo) { - validateUserAccount(userId); - validateUserAccount(socialType); - validateUserAccount(socialId); + validateUser(user); + validateSocialType(socialType); + validateSocialId(socialId); - this.userId = userId; + this.user = user; this.socialType = socialType; this.socialId = socialId; this.refreshToken = refreshToken; @@ -69,7 +69,7 @@ private SocialAuth(Long userId, SocialType socialType, String socialId, String r this.deviceInfo = deviceInfo; } - public static SocialAuth create(Long userId, SocialType socialType, String socialId, + public static SocialAuth create(User user, SocialType socialType, String socialId, String encryptedToken, String tokenHash, LocalDateTime expiredAt, String deviceInfo) { validateTokenValue(encryptedToken); @@ -77,7 +77,7 @@ public static SocialAuth create(Long userId, SocialType socialType, String socia validateExpiryTime(expiredAt); return SocialAuth.builder() - .userId(userId) + .user(user) .socialType(socialType) .socialId(socialId) .refreshToken(encryptedToken) @@ -87,14 +87,26 @@ public static SocialAuth create(Long userId, SocialType socialType, String socia .build(); } - private static void validateUserAccount(Object value) { - if (value == null || (value instanceof String && ((String) value).trim().isEmpty())) { + private static void validateUser(User user) { + if (user == null) { + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } + } + + private static void validateSocialType(SocialType socialType) { + if (socialType == null) { + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } + } + + private static void validateSocialId(String socialId) { + if (socialId == null || socialId.isBlank()) { throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); } } private static void validateTokenValue(String token) { - if (token == null || token.trim().isEmpty()) { + if (token == null || token.isBlank()) { throw new GeneralException(AuthErrorStatus.TOKEN_MISSING); } } @@ -119,6 +131,6 @@ public void updateRefreshToken(String encryptedToken, String tokenHash, LocalDat public void expireToken() { this.refreshToken = null; this.refreshTokenHash = null; - this.expiredAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); // null 대신 현재 시각 기록 + this.expiredAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); } } \ No newline at end of file From 9c26a572a12a1b8ece0b237eb43de97099271b44 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Mon, 27 Jul 2026 23:37:30 +0900 Subject: [PATCH 09/51] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=9D=B8,=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=20api=20=EC=99=84=EC=84=B1?= =?UTF-8?q?(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/auth/dto/OAuthUserInfo.java | 2 +- .../domain/auth/dto/res/AuthResponseDTO.java | 1 + .../auth/repository/SocialAuthRepository.java | 3 +- .../mr/domain/auth/service/AuthService.java | 14 ++------ .../auth/service/OAuthClientService.java | 28 +++++++++++++--- .../global/security/jwt/JwtTokenProvider.java | 33 +++++++++++++++++++ 6 files changed, 62 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java b/src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java index 25a74adc..a198017b 100644 --- a/src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java +++ b/src/main/java/com/mr/domain/auth/dto/OAuthUserInfo.java @@ -4,6 +4,6 @@ @Builder public record OAuthUserInfo( - Long socialId, + String socialId, String profileImgUrl ) {} diff --git a/src/main/java/com/mr/domain/auth/dto/res/AuthResponseDTO.java b/src/main/java/com/mr/domain/auth/dto/res/AuthResponseDTO.java index 7c9c1d0a..fe2f9ccb 100644 --- a/src/main/java/com/mr/domain/auth/dto/res/AuthResponseDTO.java +++ b/src/main/java/com/mr/domain/auth/dto/res/AuthResponseDTO.java @@ -16,6 +16,7 @@ public record LoginResponse( Long userId, String nickname, boolean isNewUser, + boolean isOnboardingCompleted, TokenResponse tokenInfo ) {} } \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java b/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java index 320b9ada..db635ca7 100644 --- a/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java +++ b/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java @@ -2,14 +2,13 @@ import com.mr.domain.auth.entity.SocialAuth; import com.mr.domain.auth.entity.enums.SocialType; -import com.mr.domain.user.entity.User; import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; public interface SocialAuthRepository extends JpaRepository { - Optional findByUserId(Long userId); + Optional findByUser_UserId(Long userId); Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId); } \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index c79fb59a..1995428d 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -26,32 +26,26 @@ public class AuthService { @Transactional public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String oAuthAccessToken, String deviceInfo) { - // 1. 소셜 프로필 조회 OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, oAuthAccessToken); - // 2. SocialAuth 존재 여부 확인 및 회원가입/로그인 진행 - // [수정] OAuthUserInfo.getSocialId() -> userInfo.getSocialId() - SocialAuth socialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.getSocialId()) + SocialAuth socialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId()) .orElse(null); boolean isNewUser = false; User user; if (socialAuth == null) { - // 신규 유저 생성 (OAuth 가입 단계) - user = userRepository.save(User.createFromOAuth(userInfo.getProfileImgUrl())); + user = userRepository.save(User.createFromOAuth(userInfo.profileImgUrl())); isNewUser = true; - // Refresh Token 생성 및 저장 String refreshToken = jwtTokenProvider.createRefreshToken(user.getUserId()); String refreshTokenHash = jwtTokenProvider.hashToken(refreshToken); LocalDateTime expiredAt = jwtTokenProvider.getRefreshTokenExpiryTime(); - // SocialAuth 정보 신규 적재 socialAuth = SocialAuth.create( user, socialType, - userInfo.getSocialId(), + userInfo.socialId(), refreshToken, refreshTokenHash, expiredAt, @@ -59,7 +53,6 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String o ); socialAuthRepository.save(socialAuth); } else { - // 기존 유저 로그인 -> Refresh Token 갱신 user = socialAuth.getUser(); String refreshToken = jwtTokenProvider.createRefreshToken(user.getUserId()); @@ -69,7 +62,6 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String o socialAuth.updateRefreshToken(refreshToken, refreshTokenHash, newExpiredAt, deviceInfo); } - // 3. 서비스 전용 AccessToken 생성 및 응답 반환 String appAccessToken = jwtTokenProvider.createAccessToken(user.getUserId()); AuthResponseDTO.TokenResponse tokenResponse = AuthResponseDTO.TokenResponse.builder() diff --git a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java index a899af9a..81362b22 100644 --- a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java +++ b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java @@ -4,12 +4,15 @@ import com.mr.domain.auth.entity.enums.SocialType; import com.mr.global.apipayload.code.CommonStatus; import com.mr.global.apipayload.exception.GeneralException; +import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestClient; +import lombok.extern.slf4j.Slf4j; import java.util.Map; - +@Slf4j @Service public class OAuthClientService { @@ -24,23 +27,38 @@ public OAuthUserInfo getUserInfo(SocialType socialType, String accessToken) { private OAuthUserInfo getKakaoUserInfo(String accessToken) { try { - Map response = restClient.get() + Map response = restClient.get() .uri("https://kapi.kakao.com/v2/user/me") .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded;charset=utf-8") .retrieve() - .body(Map.class); + .body(new ParameterizedTypeReference>() {}); + + if (response == null) { + throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); + } String socialId = String.valueOf(response.get("id")); - Map kakaoAccount = (Map) response.get("kakao_account"); - Map profile = kakaoAccount != null ? (Map) kakaoAccount.get("profile") : null; + @SuppressWarnings("unchecked") + Map kakaoAccount = (Map) response.get("kakao_account"); + + @SuppressWarnings("unchecked") + Map profile = kakaoAccount != null ? (Map) kakaoAccount.get("profile") : null; String profileImgUrl = profile != null ? (String) profile.get("profile_image_url") : null; return OAuthUserInfo.builder() .socialId(socialId) .profileImgUrl(profileImgUrl) .build(); + + } catch (HttpClientErrorException e) { + // 🌟 카카오 서버가 거부한 진짜 이유(HTTP 상태코드 및 응답 바디)를 콘솔에 출력합니다! + log.error("카카오 OAuth API 호출 실패 - Status: {}, Response: {}", + e.getStatusCode(), e.getResponseBodyAsString()); + throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); } catch (Exception e) { + log.error("OAuth 처리 중 자바 내부 예외 발생", e); throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); } } diff --git a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java index 67423040..1876acbb 100644 --- a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java @@ -16,7 +16,12 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; +import java.nio.charset.StandardCharsets; import java.security.Key; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.Date; @Component @@ -27,6 +32,7 @@ public class JwtTokenProvider { private static final String TOKEN_TYPE_CLAIM = "type"; private static final String ACCESS_TYPE = "access"; private static final String REFRESH_TYPE = "refresh"; + private static final String SEOUL_ZONE = "Asia/Seoul"; private final CustomUserDetailsService userDetailsService; private final JwtProperties jwtProperties; @@ -104,4 +110,31 @@ private Claims parseClaims(String token) { throw new JwtException("만료된 토큰입니다.", e); } } + + public String hashToken(String token) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] encodedHash = digest.digest(token.getBytes(StandardCharsets.UTF_8)); + StringBuilder hexString = new StringBuilder(2 * encodedHash.length); + for (byte b : encodedHash) { + String hex = Integer.toHexString(0xff & b); + if (hex.length() == 1) { + hexString.append('0'); + } + hexString.append(hex); + } + return hexString.toString(); + } catch (NoSuchAlgorithmException e) { + throw new RuntimeException("SHA-256 알고리즘을 찾을 수 없습니다.", e); + } + } + + public LocalDateTime getRefreshTokenExpiryTime() { + return LocalDateTime.now(ZoneId.of(SEOUL_ZONE)) + .plusSeconds(jwtProperties.refreshTokenValidityInSeconds()); + } + + public Long getAccessTokenExpirationSeconds() { + return jwtProperties.accessTokenValidityInSeconds(); + } } \ No newline at end of file From 5f1bf67dc91e03bb7fef36a2bbc32a52496a0829 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Tue, 28 Jul 2026 00:17:09 +0900 Subject: [PATCH 10/51] =?UTF-8?q?feat:=20=EB=A6=AC=ED=94=84=EB=9E=98?= =?UTF-8?q?=EC=8B=9C=20=ED=86=A0=ED=81=B0=20api=20=EA=B5=AC=ED=98=84(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 28 ++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/auth/controller/AuthController.java b/src/main/java/com/mr/domain/auth/controller/AuthController.java index dd2efe81..307213e9 100644 --- a/src/main/java/com/mr/domain/auth/controller/AuthController.java +++ b/src/main/java/com/mr/domain/auth/controller/AuthController.java @@ -9,12 +9,12 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; @@ -43,4 +43,30 @@ public ApiResponse socialLogin( AuthResponseDTO.LoginResponse response = authService.socialLogin(socialType, request.accessToken(), deviceInfo); return ApiResponse.onSuccess(response); } + @Operation(summary = "토큰 재발급 API", description = "만료된 Access Token을 Refresh Token을 이용해 재발급합니다.") + @PostMapping("/reissue") + public ApiResponse reissue( + @RequestBody @Valid AuthRequestDTO.TokenRefreshRequest request + ) { + AuthResponseDTO.TokenInfo tokenInfo = authService.reissueToken(request.refreshToken()); + return ApiResponse.onSuccess(tokenInfo); + } + + @Operation(summary = "로그아웃 API", description = "현재 로그인된 사용자의 세션 및 토큰을 무효화합니다.") + @PostMapping("/logout") + public ApiResponse logout( + @Parameter(hidden = true) @RequestAttribute("userId") Long userId + ) { + authService.logout(userId); + return ApiResponse.onSuccess(null); + } + + @Operation(summary = "회원 탈퇴 API", description = "사용자 계정을 탈퇴 처리하고 관련 인증 정보를 삭제/만료합니다.") + @PostMapping("/withdraw") + public ApiResponse withdraw( + @Parameter(hidden = true) @RequestAttribute("userId") Long userId + ) { + authService.withdraw(userId); + return ApiResponse.onSuccess(null); + } } \ No newline at end of file From 12c45bc43ddb62c0c4e7e11895ad7cf48bfb0bb3 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Tue, 28 Jul 2026 00:17:48 +0900 Subject: [PATCH 11/51] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20api=20=EA=B5=AC=ED=98=84(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mr/domain/auth/dto/res/AuthResponseDTO.java | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/main/java/com/mr/domain/auth/dto/res/AuthResponseDTO.java b/src/main/java/com/mr/domain/auth/dto/res/AuthResponseDTO.java index fe2f9ccb..e45967ed 100644 --- a/src/main/java/com/mr/domain/auth/dto/res/AuthResponseDTO.java +++ b/src/main/java/com/mr/domain/auth/dto/res/AuthResponseDTO.java @@ -11,6 +11,13 @@ public record TokenResponse( Long accessTokenExpiresInSeconds ) {} + @Builder + public record TokenInfo( + String accessToken, + String refreshToken, + Long accessTokenExpiresInSeconds + ) {} + @Builder public record LoginResponse( Long userId, From e83407297b5a4e2c897c8b342e4fe45df687c0bd Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Tue, 28 Jul 2026 00:18:08 +0900 Subject: [PATCH 12/51] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9B=90=20=ED=83=88?= =?UTF-8?q?=ED=87=B4=20api=20=EA=B5=AC=ED=98=84(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/auth/service/AuthService.java | 141 ++++++++++++------ .../auth/service/OAuthClientService.java | 9 +- 2 files changed, 100 insertions(+), 50 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 1995428d..72e3e028 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -4,72 +4,48 @@ import com.mr.domain.auth.dto.res.AuthResponseDTO; import com.mr.domain.auth.entity.SocialAuth; import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.domain.auth.exception.AuthErrorStatus; import com.mr.domain.auth.repository.SocialAuthRepository; import com.mr.domain.user.entity.User; import com.mr.domain.user.repository.UserRepository; +import com.mr.domain.user.exception.UserErrorStatus; +import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.security.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; - @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class AuthService { - private final OAuthClientService oAuthClientService; - private final UserRepository userRepository; private final SocialAuthRepository socialAuthRepository; - private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + private final JwtTokenProvider tokenProvider; + private final OAuthClientService oAuthClientService; @Transactional - public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String oAuthAccessToken, String deviceInfo) { - OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, oAuthAccessToken); + public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken, String deviceInfo) { + OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, accessToken); SocialAuth socialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId()) - .orElse(null); - - boolean isNewUser = false; - User user; - - if (socialAuth == null) { - user = userRepository.save(User.createFromOAuth(userInfo.profileImgUrl())); - isNewUser = true; - - String refreshToken = jwtTokenProvider.createRefreshToken(user.getUserId()); - String refreshTokenHash = jwtTokenProvider.hashToken(refreshToken); - LocalDateTime expiredAt = jwtTokenProvider.getRefreshTokenExpiryTime(); - - socialAuth = SocialAuth.create( - user, - socialType, - userInfo.socialId(), - refreshToken, - refreshTokenHash, - expiredAt, - deviceInfo - ); - socialAuthRepository.save(socialAuth); - } else { - user = socialAuth.getUser(); - - String refreshToken = jwtTokenProvider.createRefreshToken(user.getUserId()); - String refreshTokenHash = jwtTokenProvider.hashToken(refreshToken); - LocalDateTime newExpiredAt = jwtTokenProvider.getRefreshTokenExpiryTime(); - - socialAuth.updateRefreshToken(refreshToken, refreshTokenHash, newExpiredAt, deviceInfo); - } + .orElseGet(() -> registerNewUser(socialType, userInfo, deviceInfo)); - String appAccessToken = jwtTokenProvider.createAccessToken(user.getUserId()); + User user = socialAuth.getUser(); + String newAccessToken = tokenProvider.createAccessToken(user.getUserId()); + String newRefreshToken = tokenProvider.createRefreshToken(user.getUserId()); + String refreshTokenHash = tokenProvider.hashToken(newRefreshToken); + socialAuth.updateRefreshToken(newRefreshToken, refreshTokenHash, tokenProvider.getRefreshTokenExpiryTime(), deviceInfo); AuthResponseDTO.TokenResponse tokenResponse = AuthResponseDTO.TokenResponse.builder() - .accessToken(appAccessToken) - .refreshToken(socialAuth.getRefreshToken()) - .accessTokenExpiresInSeconds(jwtTokenProvider.getAccessTokenExpirationSeconds()) + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .accessTokenExpiresInSeconds(tokenProvider.getAccessTokenExpirationSeconds()) .build(); + boolean isNewUser = socialAuth.getCreatedAt().isAfter(java.time.LocalDateTime.now().minusMinutes(1)); + return AuthResponseDTO.LoginResponse.builder() .userId(user.getUserId()) .nickname(user.getNickname()) @@ -78,4 +54,83 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String o .tokenInfo(tokenResponse) .build(); } + + private SocialAuth registerNewUser(SocialType socialType, OAuthUserInfo userInfo, String deviceInfo) { + // 🌟 프로필 이미지가 null인 경우 기본 이미지 URL로 대체 (또는 팀 내 공통 기본 이미지 상수 사용) + String profileImgUrl = userInfo.profileImgUrl(); + if (profileImgUrl == null || profileImgUrl.isBlank()) { + profileImgUrl = "https://example.com/default-profile.png"; // 팀에서 쓰는 기본 이미지 URL로 변경 + } + + // 🌟 User 엔티티 생성 팩토리 메서드 호출 + User user = User.createFromOAuth(profileImgUrl); + userRepository.save(user); + + // 초기 토큰 값 생성 후 SocialAuth 매핑 + String initialToken = tokenProvider.createRefreshToken(user.getUserId()); + String initialHash = tokenProvider.hashToken(initialToken); + + SocialAuth socialAuth = SocialAuth.create( + user, + socialType, + userInfo.socialId(), + initialToken, + initialHash, + tokenProvider.getRefreshTokenExpiryTime(), + deviceInfo + ); + + return socialAuthRepository.save(socialAuth); + } + + @Transactional + public AuthResponseDTO.TokenInfo reissueToken(String refreshToken) { + if (!tokenProvider.validateRefreshToken(refreshToken)) { + throw new GeneralException(AuthErrorStatus.INVALID_TOKEN); + } + + Long userId = Long.valueOf(tokenProvider.getAuthentication(refreshToken).getName()); + + SocialAuth socialAuth = socialAuthRepository.findByUser_UserId(userId) + .orElseThrow(() -> new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST)); + + String requestTokenHash = tokenProvider.hashToken(refreshToken); + if (!requestTokenHash.equals(socialAuth.getRefreshTokenHash())) { + throw new GeneralException(AuthErrorStatus.REVOKED_TOKEN); + } + + String newAccessToken = tokenProvider.createAccessToken(userId); + String newRefreshToken = tokenProvider.createRefreshToken(userId); + String newRefreshTokenHash = tokenProvider.hashToken(newRefreshToken); + + socialAuth.updateRefreshToken(newRefreshToken, newRefreshTokenHash, tokenProvider.getRefreshTokenExpiryTime(), socialAuth.getDeviceInfo()); + + return AuthResponseDTO.TokenInfo.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .accessTokenExpiresInSeconds(tokenProvider.getAccessTokenExpirationSeconds()) + .build(); + } + + @Transactional + public void logout(Long userId) { + SocialAuth socialAuth = socialAuthRepository.findByUser_UserId(userId) + .orElseThrow(() -> new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST)); + + socialAuth.expireToken(); + } + + @Transactional + public void withdraw(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); + + SocialAuth socialAuth = socialAuthRepository.findByUser_UserId(userId).orElse(null); + if (socialAuth != null) { + socialAuth.expireToken(); + socialAuthRepository.delete(socialAuth); + } + + userRepository.delete(user); + } } \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java index 81362b22..a2bed1f5 100644 --- a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java +++ b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java @@ -32,7 +32,8 @@ private OAuthUserInfo getKakaoUserInfo(String accessToken) { .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded;charset=utf-8") .retrieve() - .body(new ParameterizedTypeReference>() {}); + .body(new ParameterizedTypeReference>() { + }); if (response == null) { throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); @@ -52,13 +53,7 @@ private OAuthUserInfo getKakaoUserInfo(String accessToken) { .profileImgUrl(profileImgUrl) .build(); - } catch (HttpClientErrorException e) { - // 🌟 카카오 서버가 거부한 진짜 이유(HTTP 상태코드 및 응답 바디)를 콘솔에 출력합니다! - log.error("카카오 OAuth API 호출 실패 - Status: {}, Response: {}", - e.getStatusCode(), e.getResponseBodyAsString()); - throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); } catch (Exception e) { - log.error("OAuth 처리 중 자바 내부 예외 발생", e); throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); } } From 503d89e671bdaf874d26970e87c32bf291ce2cb4 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Tue, 28 Jul 2026 00:40:05 +0900 Subject: [PATCH 13/51] =?UTF-8?q?feat:=20UserId=20->=20User=5FUserId?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/auth/service/AuthService.java | 7 +++---- .../statistics/repository/UserStatisticsRepository.java | 2 +- .../com/mr/domain/user/service/UserProfileService.java | 2 +- .../com/mr/domain/user/service/UserProfileServiceTest.java | 2 +- 4 files changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 72e3e028..500307ba 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -25,6 +25,8 @@ public class AuthService { private final JwtTokenProvider tokenProvider; private final OAuthClientService oAuthClientService; + + @Transactional public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken, String deviceInfo) { OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, accessToken); @@ -56,17 +58,14 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String a } private SocialAuth registerNewUser(SocialType socialType, OAuthUserInfo userInfo, String deviceInfo) { - // 🌟 프로필 이미지가 null인 경우 기본 이미지 URL로 대체 (또는 팀 내 공통 기본 이미지 상수 사용) String profileImgUrl = userInfo.profileImgUrl(); if (profileImgUrl == null || profileImgUrl.isBlank()) { - profileImgUrl = "https://example.com/default-profile.png"; // 팀에서 쓰는 기본 이미지 URL로 변경 + profileImgUrl = "https://example.com/default-profile.png"; } - // 🌟 User 엔티티 생성 팩토리 메서드 호출 User user = User.createFromOAuth(profileImgUrl); userRepository.save(user); - // 초기 토큰 값 생성 후 SocialAuth 매핑 String initialToken = tokenProvider.createRefreshToken(user.getUserId()); String initialHash = tokenProvider.hashToken(initialToken); diff --git a/src/main/java/com/mr/domain/statistics/repository/UserStatisticsRepository.java b/src/main/java/com/mr/domain/statistics/repository/UserStatisticsRepository.java index 4b03e947..9dba7dc4 100644 --- a/src/main/java/com/mr/domain/statistics/repository/UserStatisticsRepository.java +++ b/src/main/java/com/mr/domain/statistics/repository/UserStatisticsRepository.java @@ -6,5 +6,5 @@ public interface UserStatisticsRepository extends JpaRepository { - Optional findByUserId(Long userId); + Optional findByUser_UserId(Long userId); } diff --git a/src/main/java/com/mr/domain/user/service/UserProfileService.java b/src/main/java/com/mr/domain/user/service/UserProfileService.java index 186a04ac..a58e5ca2 100644 --- a/src/main/java/com/mr/domain/user/service/UserProfileService.java +++ b/src/main/java/com/mr/domain/user/service/UserProfileService.java @@ -184,7 +184,7 @@ private void ensureNicknameNotTaken(Long userId, String trimmedNickname) { } private UserProfileResponseDTO.StatisticsResponse buildStatistics(Long userId) { - UserStatistics stats = userStatisticsRepository.findByUserId(userId).orElse(null); + UserStatistics stats = userStatisticsRepository.findByUser_UserId(userId).orElse(null); return UserProfileResponseDTO.StatisticsResponse.builder() .practiceSessionCount(stats == null ? 0L : stats.getTotalPracticeCount().longValue()) diff --git a/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java index b6c5f4b5..90a09532 100644 --- a/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java +++ b/src/test/java/com/mr/domain/user/service/UserProfileServiceTest.java @@ -212,7 +212,7 @@ void getMyProfile_usesLearningProgressAggregationForCompletedLearningCount() { .findFirstByUserAndStartDateLessThanEqualAndEndDateGreaterThanEqualOrderByStartDateDesc( any(), any(), any())) .willReturn(Optional.of(subscription)); - given(userStatisticsRepository.findByUserId(USER_ID)).willReturn(Optional.empty()); + given(userStatisticsRepository.findByUser_UserId(USER_ID)).willReturn(Optional.empty()); given(userLearningProgressRepository.countDistinctCompletedLearningsByUserId(USER_ID)).willReturn(8L); UserProfileResponseDTO.ProfileResponse response = userProfileService.getMyProfile(); From 65eb0924a1f741dee527e69d66796a1159764031 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Tue, 28 Jul 2026 00:43:25 +0900 Subject: [PATCH 14/51] =?UTF-8?q?feat:=20PUBLIC=5FURLS=EC=97=90=20/api/aut?= =?UTF-8?q?h/reissue=20=EB=93=B1=EB=A1=9D(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/global/security/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/mr/global/security/SecurityConfig.java b/src/main/java/com/mr/global/security/SecurityConfig.java index b28f5968..ce1e68bd 100644 --- a/src/main/java/com/mr/global/security/SecurityConfig.java +++ b/src/main/java/com/mr/global/security/SecurityConfig.java @@ -32,6 +32,7 @@ public class SecurityConfig { "/swagger-ui/**", "/v3/api-docs/**", "/api/auth/login/**", + "/api/auth/reissue", "/api/auth/refactor" }; From d952845abd76947179a9705ef0f1e9b81c6cbb86 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 00:43:49 +0900 Subject: [PATCH 15/51] =?UTF-8?q?feat:=20=EC=8B=A0=EA=B7=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=97=AC=EB=B6=80=20=EC=B6=94=EA=B0=80=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/auth/service/AuthService.java | 17 ++- .../domain/auth/service/AuthServiceTest.java | 103 ++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 src/test/java/com/mr/domain/auth/service/AuthServiceTest.java diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 500307ba..5596da73 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -12,9 +12,12 @@ import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.security.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.Optional; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -25,14 +28,18 @@ public class AuthService { private final JwtTokenProvider tokenProvider; private final OAuthClientService oAuthClientService; + @Value("${app.profile.default-image-url:https://musereview-storage-526426842030-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/profile/default-profile.png}") + private String defaultProfileImageUrl; + @Transactional public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken, String deviceInfo) { OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, accessToken); - SocialAuth socialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId()) - .orElseGet(() -> registerNewUser(socialType, userInfo, deviceInfo)); + Optional optionalSocialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId()); + boolean isNewUser = optionalSocialAuth.isEmpty(); + SocialAuth socialAuth = optionalSocialAuth.orElseGet(() -> registerNewUser(socialType, userInfo, deviceInfo)); User user = socialAuth.getUser(); String newAccessToken = tokenProvider.createAccessToken(user.getUserId()); @@ -46,8 +53,6 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String a .accessTokenExpiresInSeconds(tokenProvider.getAccessTokenExpirationSeconds()) .build(); - boolean isNewUser = socialAuth.getCreatedAt().isAfter(java.time.LocalDateTime.now().minusMinutes(1)); - return AuthResponseDTO.LoginResponse.builder() .userId(user.getUserId()) .nickname(user.getNickname()) @@ -60,7 +65,9 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String a private SocialAuth registerNewUser(SocialType socialType, OAuthUserInfo userInfo, String deviceInfo) { String profileImgUrl = userInfo.profileImgUrl(); if (profileImgUrl == null || profileImgUrl.isBlank()) { - profileImgUrl = "https://example.com/default-profile.png"; + profileImgUrl = (defaultProfileImageUrl != null && !defaultProfileImageUrl.isBlank()) + ? defaultProfileImageUrl + : "https://musereview-storage-526426842030-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/profile/default-profile.png"; } User user = User.createFromOAuth(profileImgUrl); diff --git a/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java b/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..1a9def87 --- /dev/null +++ b/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java @@ -0,0 +1,103 @@ +package com.mr.domain.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +import com.mr.domain.auth.dto.OAuthUserInfo; +import com.mr.domain.auth.dto.res.AuthResponseDTO; +import com.mr.domain.auth.entity.SocialAuth; +import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.domain.auth.repository.SocialAuthRepository; +import com.mr.domain.user.entity.User; +import com.mr.domain.user.repository.UserRepository; +import com.mr.global.security.jwt.JwtTokenProvider; +import java.time.LocalDateTime; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private SocialAuthRepository socialAuthRepository; + @Mock + private UserRepository userRepository; + @Mock + private JwtTokenProvider tokenProvider; + @Mock + private OAuthClientService oAuthClientService; + + @InjectMocks + private AuthService authService; + + private OAuthUserInfo userInfo; + + @BeforeEach + void setUp() { + userInfo = new OAuthUserInfo("12345", "https://example.com/profile.png"); + } + + @Test + @DisplayName("socialLogin - 최초 로그인 시 isNewUser=true로 반환된다") + void socialLogin_newUser_returnsIsNewUserTrue() { + // given + given(oAuthClientService.getUserInfo(SocialType.KAKAO, "access_token")).willReturn(userInfo); + given(socialAuthRepository.findBySocialTypeAndSocialId(SocialType.KAKAO, "12345")) + .willReturn(Optional.empty()); + + User newUser = User.createFromOAuth(userInfo.profileImgUrl()); + given(userRepository.save(any(User.class))).willReturn(newUser); + given(tokenProvider.createAccessToken(any())).willReturn("new_access_token"); + given(tokenProvider.createRefreshToken(any())).willReturn("new_refresh_token"); + given(tokenProvider.hashToken(any())).willReturn("token_hash"); + given(tokenProvider.getRefreshTokenExpiryTime()).willReturn(LocalDateTime.now().plusDays(7)); + given(tokenProvider.getAccessTokenExpirationSeconds()).willReturn(3600L); + + SocialAuth savedSocialAuth = SocialAuth.create( + newUser, SocialType.KAKAO, "12345", "new_refresh_token", "token_hash", LocalDateTime.now().plusDays(7), "deviceInfo" + ); + given(socialAuthRepository.save(any(SocialAuth.class))).willReturn(savedSocialAuth); + + // when + AuthResponseDTO.LoginResponse response = authService.socialLogin(SocialType.KAKAO, "access_token", "deviceInfo"); + + // then + assertThat(response.isNewUser()).isTrue(); + verify(userRepository).save(any(User.class)); + verify(socialAuthRepository).save(any(SocialAuth.class)); + } + + @Test + @DisplayName("socialLogin - 기존 회원 로그인 시 isNewUser=false로 반환된다") + void socialLogin_existingUser_returnsIsNewUserFalse() { + // given + given(oAuthClientService.getUserInfo(SocialType.KAKAO, "access_token")).willReturn(userInfo); + + User existingUser = User.createFromOAuth(userInfo.profileImgUrl()); + SocialAuth existingSocialAuth = SocialAuth.create( + existingUser, SocialType.KAKAO, "12345", "old_token", "old_hash", LocalDateTime.now().plusDays(7), "deviceInfo" + ); + given(socialAuthRepository.findBySocialTypeAndSocialId(SocialType.KAKAO, "12345")) + .willReturn(Optional.of(existingSocialAuth)); + + given(tokenProvider.createAccessToken(any())).willReturn("new_access_token"); + given(tokenProvider.createRefreshToken(any())).willReturn("new_refresh_token"); + given(tokenProvider.hashToken(any())).willReturn("token_hash"); + given(tokenProvider.getRefreshTokenExpiryTime()).willReturn(LocalDateTime.now().plusDays(7)); + given(tokenProvider.getAccessTokenExpirationSeconds()).willReturn(3600L); + + // when + AuthResponseDTO.LoginResponse response = authService.socialLogin(SocialType.KAKAO, "access_token", "deviceInfo"); + + // then + assertThat(response.isNewUser()).isFalse(); + } +} From 867caa29ffac48ca726c67ce126255098913b29f Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 00:45:55 +0900 Subject: [PATCH 16/51] =?UTF-8?q?feat:=20OAuth=20=ED=81=B4=EB=9D=BC?= =?UTF-8?q?=EC=9D=B4=EC=96=B8=ED=8A=B8=20=ED=83=80=EC=9E=84=EC=95=84?= =?UTF-8?q?=EC=9B=83=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/OAuthClientService.java | 23 ++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java index a2bed1f5..65dc07bd 100644 --- a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java +++ b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java @@ -4,6 +4,9 @@ import com.mr.domain.auth.entity.enums.SocialType; import com.mr.global.apipayload.code.CommonStatus; import com.mr.global.apipayload.exception.GeneralException; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.ClientHttpRequestFactories; +import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; @@ -11,12 +14,30 @@ import org.springframework.web.client.RestClient; import lombok.extern.slf4j.Slf4j; +import java.time.Duration; import java.util.Map; + @Slf4j @Service public class OAuthClientService { - private final RestClient restClient = RestClient.create(); + private final RestClient restClient; + + public OAuthClientService( + @Value("${oauth.connect-timeout:3s}") Duration connectTimeout, + @Value("${oauth.read-timeout:5s}") Duration readTimeout) { + ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS + .withConnectTimeout(connectTimeout) + .withReadTimeout(readTimeout); + + this.restClient = RestClient.builder() + .requestFactory(ClientHttpRequestFactories.get(settings)) + .build(); + } + + public OAuthClientService(RestClient restClient) { + this.restClient = restClient; + } public OAuthUserInfo getUserInfo(SocialType socialType, String accessToken) { return switch (socialType) { From 5d2aed43c9e2e6566b6d62402ec18fd93e967a97 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 00:46:14 +0900 Subject: [PATCH 17/51] =?UTF-8?q?feat:=20OAuth=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EA=B0=9D=EC=B2=B4=20=EC=83=9D=EC=84=B1=20=EC=A0=95?= =?UTF-8?q?=EC=83=81=20=EB=8F=99=EC=9E=91=20=EA=B2=80=EC=A6=9D=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/OAuthClientServiceTest.java | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java diff --git a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java new file mode 100644 index 00000000..f6bcaccb --- /dev/null +++ b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java @@ -0,0 +1,21 @@ +package com.mr.domain.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class OAuthClientServiceTest { + + @Test + @DisplayName("OAuthClientService 생성 시 connect/read timeout 설정이 정상 적용된다") + void createOAuthClientService_withTimeout() { + Duration connectTimeout = Duration.ofSeconds(3); + Duration readTimeout = Duration.ofSeconds(5); + + OAuthClientService service = new OAuthClientService(connectTimeout, readTimeout); + + assertThat(service).isNotNull(); + } +} From ac7e9121f126cf1347d23e142b5ace34a0375cc6 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 00:47:28 +0900 Subject: [PATCH 18/51] =?UTF-8?q?feat:=20socialId=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/OAuthClientService.java | 35 +++++++++---- .../auth/service/OAuthClientServiceTest.java | 50 +++++++++++++++++++ 2 files changed, 75 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java index 65dc07bd..bf902249 100644 --- a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java +++ b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java @@ -56,11 +56,7 @@ private OAuthUserInfo getKakaoUserInfo(String accessToken) { .body(new ParameterizedTypeReference>() { }); - if (response == null) { - throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); - } - - String socialId = String.valueOf(response.get("id")); + String socialId = extractSocialId(response); @SuppressWarnings("unchecked") Map kakaoAccount = (Map) response.get("kakao_account"); @@ -74,6 +70,8 @@ private OAuthUserInfo getKakaoUserInfo(String accessToken) { .profileImgUrl(profileImgUrl) .build(); + } catch (GeneralException e) { + throw e; } catch (Exception e) { throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); } @@ -81,22 +79,39 @@ private OAuthUserInfo getKakaoUserInfo(String accessToken) { private OAuthUserInfo getGoogleUserInfo(String accessToken) { try { - Map response = restClient.get() + Map response = restClient.get() .uri("https://www.googleapis.com/oauth2/v2/userinfo") .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .retrieve() - .body(Map.class); + .body(new ParameterizedTypeReference>() { + }); - String socialId = (String) response.get("id"); - String email = (String) response.get("email"); - String profileImgUrl = (String) response.get("picture"); + String socialId = extractSocialId(response); + String profileImgUrl = response != null ? (String) response.get("picture") : null; return OAuthUserInfo.builder() .socialId(socialId) .profileImgUrl(profileImgUrl) .build(); + } catch (GeneralException e) { + throw e; } catch (Exception e) { throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); } } + + private String extractSocialId(Map response) { + if (response == null) { + throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); + } + Object idObj = response.get("id"); + if (idObj == null) { + throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); + } + String socialId = String.valueOf(idObj); + if (socialId.isBlank() || "null".equalsIgnoreCase(socialId.trim())) { + throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); + } + return socialId; + } } \ No newline at end of file diff --git a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java index f6bcaccb..76a19c1b 100644 --- a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java @@ -1,13 +1,33 @@ package com.mr.domain.auth.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.global.apipayload.exception.GeneralException; import java.time.Duration; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.test.web.client.match.MockRestRequestMatchers; +import org.springframework.test.web.client.response.MockRestResponseCreators; +import org.springframework.web.client.RestClient; class OAuthClientServiceTest { + private RestClient.Builder restClientBuilder; + private MockRestServiceServer mockServer; + private OAuthClientService oAuthClientService; + + @BeforeEach + void setUp() { + restClientBuilder = RestClient.builder(); + mockServer = MockRestServiceServer.bindTo(restClientBuilder).build(); + oAuthClientService = new OAuthClientService(restClientBuilder.build()); + } + @Test @DisplayName("OAuthClientService 생성 시 connect/read timeout 설정이 정상 적용된다") void createOAuthClientService_withTimeout() { @@ -18,4 +38,34 @@ void createOAuthClientService_withTimeout() { assertThat(service).isNotNull(); } + + @Test + @DisplayName("카카오 response의 id가 null이면 GeneralException이 발생한다") + void getKakaoUserInfo_nullId_throwsException() { + mockServer.expect(MockRestRequestMatchers.requestTo("https://kapi.kakao.com/v2/user/me")) + .andRespond(MockRestResponseCreators.withSuccess("{\"id\": null}", MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.KAKAO, "token")) + .isInstanceOf(GeneralException.class); + } + + @Test + @DisplayName("카카오 response의 id가 'null' 문자열이면 GeneralException이 발생한다") + void getKakaoUserInfo_literalNullId_throwsException() { + mockServer.expect(MockRestRequestMatchers.requestTo("https://kapi.kakao.com/v2/user/me")) + .andRespond(MockRestResponseCreators.withSuccess("{\"id\": \"null\"}", MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.KAKAO, "token")) + .isInstanceOf(GeneralException.class); + } + + @Test + @DisplayName("구글 response의 id가 없으면 GeneralException이 발생한다") + void getGoogleUserInfo_missingId_throwsException() { + mockServer.expect(MockRestRequestMatchers.requestTo("https://www.googleapis.com/oauth2/v2/userinfo")) + .andRespond(MockRestResponseCreators.withSuccess("{\"email\": \"test@example.com\"}", MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.GOOGLE, "token")) + .isInstanceOf(GeneralException.class); + } } From 19c7bd07e1c410df1b1a1e216b7eddbdba9b8712 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 00:49:23 +0900 Subject: [PATCH 19/51] =?UTF-8?q?feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EB=B3=80=EC=88=98=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/auth/service/AuthService.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 5596da73..12f624cd 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -28,11 +28,9 @@ public class AuthService { private final JwtTokenProvider tokenProvider; private final OAuthClientService oAuthClientService; - @Value("${app.profile.default-image-url:https://musereview-storage-526426842030-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/profile/default-profile.png}") + @Value("${app.profile.default-image-url}") private String defaultProfileImageUrl; - - @Transactional public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken, String deviceInfo) { OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, accessToken); @@ -65,9 +63,7 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String a private SocialAuth registerNewUser(SocialType socialType, OAuthUserInfo userInfo, String deviceInfo) { String profileImgUrl = userInfo.profileImgUrl(); if (profileImgUrl == null || profileImgUrl.isBlank()) { - profileImgUrl = (defaultProfileImageUrl != null && !defaultProfileImageUrl.isBlank()) - ? defaultProfileImageUrl - : "https://musereview-storage-526426842030-ap-northeast-2.s3.ap-northeast-2.amazonaws.com/profile/default-profile.png"; + profileImgUrl = defaultProfileImageUrl; } User user = User.createFromOAuth(profileImgUrl); From ec853a0b4cde1da20358cb584919b8797ece876b Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 00:50:56 +0900 Subject: [PATCH 20/51] chore: remove tracked application.yml and application.example.yml from git tracking --- src/main/resources/application.example.yml | 51 ---------------- src/main/resources/application.yml | 67 ---------------------- 2 files changed, 118 deletions(-) delete mode 100644 src/main/resources/application.example.yml delete mode 100644 src/main/resources/application.yml diff --git a/src/main/resources/application.example.yml b/src/main/resources/application.example.yml deleted file mode 100644 index 18d2b032..00000000 --- a/src/main/resources/application.example.yml +++ /dev/null @@ -1,51 +0,0 @@ -# ================================================================= # -# 🔒 [NOTICE] 본 파일은 로컬 환경 세팅용 '공통 예시 파일'입니다. -# 각자 로컬 PC 사양에 맞게 정보를 입력한 후, 파일 이름을 -# 'application.yml'로 변경하여 동일한 경로에 위치시켜 주세요! -# ================================================================= # - -spring: - # 1. 로컬 데이터베이스 커넥션 설정 (PostgreSQL 18) - datasource: - driver-class-name: org.postgresql.Driver - url: jdbc:postgresql://localhost:5432/mr_db # [체크] 로컬에 mr_db 빈 데이터베이스를 먼저 생성 - username: postgres # [입력] PostgreSQL 계정명 - password: "" # [입력] PostgreSQL 비밀번호 (빈값 입력 가능) - - # 2. JPA 및 하이버네이트 구동 설정 - jpa: - hibernate: - ddl-auto: update # 엔티티 매핑 정보 변경 시 DB 테이블 자동 반영 - show-sql: true # 콘솔에 실행 SQL 포맷 출력 - properties: - hibernate: - format_sql: true - dialect: org.hibernate.dialect.PostgreSQLDialect - -# 3. Spring Security 및 JWT 인증 설정 -jwt: - # [입력] HS256 알고리즘을 충족하는 256비트(32바이트) 이상의 임의의 비밀키를 채워주세요. - # 기입 예시: "your-local-custom-secret-key-must-be-very-long-and-secure-32bytes" - secret: "" - access-token-validity-in-seconds: 1800 # Access Token 만료 시간 (30분) - refresh-token-validity-in-seconds: 604800 # Refresh Token 만료 시간 (7일) - -# 4. 외부 소셜 로그인 API 연동 정보 (OAuth용) -oauth: - kakao: - client-id: "" # [입력] 카카오 디벨로퍼스 REST API 키 - client-secret: "" # [입력] 카카오 보안 Client Secret 키 - redirect-uri: http://localhost:8080/login/oauth2/code/kakao - google: - client-id: "" # [입력] 구글 클라우드 콘솔 OAuth 클라이언트 ID - client-secret: "" # [입력] 구글 클라우드 콘솔 보안 비밀번호 - redirect-uri: http://localhost:8080/login/oauth2/code/google - -# 5. AI 서버 연동 설정 -ai: - internal: - base-url: http://localhost:8000 # [입력] 로컬에서 띄운 AI 서버 주소 - connect-timeout: 3s - read-timeout: 30s - endpoints: - analyze: /analyze \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml deleted file mode 100644 index 1ffa7a7e..00000000 --- a/src/main/resources/application.yml +++ /dev/null @@ -1,67 +0,0 @@ -spring: - config: - import: - - optional:file:.env[.properties] #.env 파일 자동 로드 - - application: - name: musereview-backend - - # 공통 데이터베이스 접속 규칙 (실제 정보는 각자 .env나 application-prod에서 오버라이딩) - datasource: - driver-class-name: org.postgresql.Driver - url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/mr_db} - username: ${SPRING_DATASOURCE_USERNAME:postgres} - password: ${SPRING_DATASOURCE_PASSWORD:} - - # 공통 JPA 및 하이버네이트 구동 설정 - jpa: - hibernate: - ddl-auto: ${SPRING_JPA_DDL_AUTO:update} # 배포 환경(prod)에선 validate로 덮어씌워짐 - show-sql: true - properties: - hibernate: - format_sql: true - dialect: org.hibernate.dialect.PostgreSQLDialect - - # 파일 업로드 용량 제한 - servlet: - multipart: - max-file-size: 20MB - max-request-size: 20MB - -# Spring Security 및 JWT 인증 설정 -jwt: - secret: ${JWT_SECRET_KEY} - access-token-validity-in-seconds: 1800 # Access Token 만료 시간 (30분) - refresh-token-validity-in-seconds: 604800 # Refresh Token 만료 시간 (7일) - -# 소셜 로그인 API 연동 -oauth: - kakao: - client-id: ${KAKAO_CLIENT_ID:} - client-secret: ${KAKAO_CLIENT_SECRET:} - redirect-uri: ${KAKAO_REDIRECT_URI:http://localhost:8080/login/oauth2/code/kakao} - - google: - client-id: ${GOOGLE_CLIENT_ID:} - client-secret: ${GOOGLE_CLIENT_SECRET:} - redirect-uri: ${GOOGLE_REDIRECT_URI:http://localhost:8080/login/oauth2/code/google} - -ai: - internal: - base-url: ${AI_INTERNAL_BASE_URL:http://localhost:8000} - connect-timeout: ${AI_INTERNAL_CONNECT_TIMEOUT:3s} - read-timeout: ${AI_INTERNAL_READ_TIMEOUT:30s} - endpoints: - analyze: ${AI_INTERNAL_ANALYZE_ENDPOINT:/analyze} - # TODO: 외부 LLM(Gemini) 연동 설정 - 리포트 생성용, 별도 이슈에서 ai.external.* 로 base-url/api-key/model 등 확정 예정 - -aws: - s3: - bucket: ${AWS_S3_BUCKET:} - region: ${AWS_REGION:ap-northeast-2} - -# 프로필 관련 공통 설정 -app: - profile: - default-image-url: ${DEFAULT_PROFILE_IMAGE_URL:} From 4006ee9eef2490ecca5b2894f7431d1207e83eac Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 00:55:08 +0900 Subject: [PATCH 21/51] =?UTF-8?q?feat:=20=ED=95=9C=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=EA=B0=80=20=EC=97=AC=EB=9F=AC=20=EC=86=8C=EC=85=9C=20auth?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=80=EC=A7=90(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/auth/entity/SocialAuth.java | 2 +- .../auth/repository/SocialAuthRepository.java | 6 ++- .../mr/domain/auth/service/AuthService.java | 26 ++++++------- .../domain/auth/service/AuthServiceTest.java | 38 +++++++++++++++++++ 4 files changed, 56 insertions(+), 16 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index 5069f1f2..cd8113ed 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -29,7 +29,7 @@ public class SocialAuth extends BaseCreatedEntity { @Column(name = "social_auth_id") private Long id; - @OneToOne(fetch = FetchType.LAZY) + @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "user_id", nullable = false) private User user; diff --git a/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java b/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java index db635ca7..276925a7 100644 --- a/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java +++ b/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java @@ -4,11 +4,13 @@ import com.mr.domain.auth.entity.enums.SocialType; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface SocialAuthRepository extends JpaRepository { - Optional findByUser_UserId(Long userId); + List findAllByUser_UserId(Long userId); Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId); - + Optional findByUser_UserIdAndSocialType(Long userId, SocialType socialType); + Optional findByRefreshTokenHash(String refreshTokenHash); } \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 12f624cd..63ed6216 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -16,6 +16,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.List; import java.util.Optional; @Service @@ -92,15 +93,12 @@ public AuthResponseDTO.TokenInfo reissueToken(String refreshToken) { } Long userId = Long.valueOf(tokenProvider.getAuthentication(refreshToken).getName()); + String requestTokenHash = tokenProvider.hashToken(refreshToken); - SocialAuth socialAuth = socialAuthRepository.findByUser_UserId(userId) + SocialAuth socialAuth = socialAuthRepository.findByRefreshTokenHash(requestTokenHash) + .filter(auth -> auth.getUser().getUserId().equals(userId)) .orElseThrow(() -> new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST)); - String requestTokenHash = tokenProvider.hashToken(refreshToken); - if (!requestTokenHash.equals(socialAuth.getRefreshTokenHash())) { - throw new GeneralException(AuthErrorStatus.REVOKED_TOKEN); - } - String newAccessToken = tokenProvider.createAccessToken(userId); String newRefreshToken = tokenProvider.createRefreshToken(userId); String newRefreshTokenHash = tokenProvider.hashToken(newRefreshToken); @@ -116,10 +114,12 @@ public AuthResponseDTO.TokenInfo reissueToken(String refreshToken) { @Transactional public void logout(Long userId) { - SocialAuth socialAuth = socialAuthRepository.findByUser_UserId(userId) - .orElseThrow(() -> new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST)); + List socialAuths = socialAuthRepository.findAllByUser_UserId(userId); + if (socialAuths.isEmpty()) { + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } - socialAuth.expireToken(); + socialAuths.forEach(SocialAuth::expireToken); } @Transactional @@ -127,10 +127,10 @@ public void withdraw(Long userId) { User user = userRepository.findById(userId) .orElseThrow(() -> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); - SocialAuth socialAuth = socialAuthRepository.findByUser_UserId(userId).orElse(null); - if (socialAuth != null) { - socialAuth.expireToken(); - socialAuthRepository.delete(socialAuth); + List socialAuths = socialAuthRepository.findAllByUser_UserId(userId); + if (!socialAuths.isEmpty()) { + socialAuths.forEach(SocialAuth::expireToken); + socialAuthRepository.deleteAll(socialAuths); } userRepository.delete(user); diff --git a/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java b/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java index 1a9def87..074e85ed 100644 --- a/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java @@ -14,6 +14,7 @@ import com.mr.domain.user.repository.UserRepository; import com.mr.global.security.jwt.JwtTokenProvider; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -100,4 +101,41 @@ void socialLogin_existingUser_returnsIsNewUserFalse() { // then assertThat(response.isNewUser()).isFalse(); } + + @Test + @DisplayName("logout - 사용자의 모든 SocialAuth 토큰을 만료 처리한다") + void logout_expiresAllSocialAuthTokens() { + // given + Long userId = 1L; + User user = User.createFromOAuth("https://example.com/profile.png"); + SocialAuth kakaoAuth = SocialAuth.create(user, SocialType.KAKAO, "k123", "t1", "h1", LocalDateTime.now().plusDays(1), "device"); + SocialAuth googleAuth = SocialAuth.create(user, SocialType.GOOGLE, "g456", "t2", "h2", LocalDateTime.now().plusDays(1), "device"); + + given(socialAuthRepository.findAllByUser_UserId(userId)).willReturn(List.of(kakaoAuth, googleAuth)); + + // when + authService.logout(userId); + + // then + verify(socialAuthRepository).findAllByUser_UserId(userId); + } + + @Test + @DisplayName("withdraw - 사용자의 모든 SocialAuth를 삭제하고 User를 삭제한다") + void withdraw_deletesAllSocialAuthsAndUser() { + // given + Long userId = 1L; + User user = User.createFromOAuth("https://example.com/profile.png"); + SocialAuth kakaoAuth = SocialAuth.create(user, SocialType.KAKAO, "k123", "t1", "h1", LocalDateTime.now().plusDays(1), "device"); + + given(userRepository.findById(userId)).willReturn(Optional.of(user)); + given(socialAuthRepository.findAllByUser_UserId(userId)).willReturn(List.of(kakaoAuth)); + + // when + authService.withdraw(userId); + + // then + verify(socialAuthRepository).deleteAll(List.of(kakaoAuth)); + verify(userRepository).delete(user); + } } From dec0289f588895a8ec8806091197b3e2e44cbbd6 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:02:53 +0900 Subject: [PATCH 22/51] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=ED=95=B4?= =?UTF-8?q?=EC=8B=9C=EA=B0=92=EB=A7=8C=20=EC=A0=80=EC=9E=A5=ED=95=98?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/auth/entity/SocialAuth.java | 15 +++------------ .../com/mr/domain/auth/service/AuthService.java | 5 ++--- 2 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index cd8113ed..5c044a29 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -40,9 +40,6 @@ public class SocialAuth extends BaseCreatedEntity { @Column(name = "social_id", nullable = false, length = 100) private String socialId; - @Column(name = "refresh_token", length = 1000) - private String refreshToken; - @Column(name = "refresh_token_hash", length = 64, unique = true) private String refreshTokenHash; @@ -53,7 +50,7 @@ public class SocialAuth extends BaseCreatedEntity { private String deviceInfo; @Builder(access = AccessLevel.PRIVATE) - private SocialAuth(User user, SocialType socialType, String socialId, String refreshToken, + private SocialAuth(User user, SocialType socialType, String socialId, String refreshTokenHash, LocalDateTime expiredAt, String deviceInfo) { validateUser(user); @@ -63,16 +60,14 @@ private SocialAuth(User user, SocialType socialType, String socialId, String ref this.user = user; this.socialType = socialType; this.socialId = socialId; - this.refreshToken = refreshToken; this.refreshTokenHash = refreshTokenHash; this.expiredAt = expiredAt; this.deviceInfo = deviceInfo; } public static SocialAuth create(User user, SocialType socialType, String socialId, - String encryptedToken, String tokenHash, LocalDateTime expiredAt, String deviceInfo) { + String tokenHash, LocalDateTime expiredAt, String deviceInfo) { - validateTokenValue(encryptedToken); validateTokenValue(tokenHash); validateExpiryTime(expiredAt); @@ -80,7 +75,6 @@ public static SocialAuth create(User user, SocialType socialType, String socialI .user(user) .socialType(socialType) .socialId(socialId) - .refreshToken(encryptedToken) .refreshTokenHash(tokenHash) .expiredAt(expiredAt) .deviceInfo(deviceInfo) @@ -117,19 +111,16 @@ private static void validateExpiryTime(LocalDateTime expiredAt) { } } - public void updateRefreshToken(String encryptedToken, String tokenHash, LocalDateTime newExpiredAt, String deviceInfo) { - validateTokenValue(encryptedToken); + public void updateRefreshToken(String tokenHash, LocalDateTime newExpiredAt, String deviceInfo) { validateTokenValue(tokenHash); validateExpiryTime(newExpiredAt); - this.refreshToken = encryptedToken; this.refreshTokenHash = tokenHash; this.expiredAt = newExpiredAt; this.deviceInfo = deviceInfo; } public void expireToken() { - this.refreshToken = null; this.refreshTokenHash = null; this.expiredAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); } diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 63ed6216..2cfe1fa1 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -45,7 +45,7 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String a String newRefreshToken = tokenProvider.createRefreshToken(user.getUserId()); String refreshTokenHash = tokenProvider.hashToken(newRefreshToken); - socialAuth.updateRefreshToken(newRefreshToken, refreshTokenHash, tokenProvider.getRefreshTokenExpiryTime(), deviceInfo); + socialAuth.updateRefreshToken(refreshTokenHash, tokenProvider.getRefreshTokenExpiryTime(), deviceInfo); AuthResponseDTO.TokenResponse tokenResponse = AuthResponseDTO.TokenResponse.builder() .accessToken(newAccessToken) .refreshToken(newRefreshToken) @@ -77,7 +77,6 @@ private SocialAuth registerNewUser(SocialType socialType, OAuthUserInfo userInfo user, socialType, userInfo.socialId(), - initialToken, initialHash, tokenProvider.getRefreshTokenExpiryTime(), deviceInfo @@ -103,7 +102,7 @@ public AuthResponseDTO.TokenInfo reissueToken(String refreshToken) { String newRefreshToken = tokenProvider.createRefreshToken(userId); String newRefreshTokenHash = tokenProvider.hashToken(newRefreshToken); - socialAuth.updateRefreshToken(newRefreshToken, newRefreshTokenHash, tokenProvider.getRefreshTokenExpiryTime(), socialAuth.getDeviceInfo()); + socialAuth.updateRefreshToken(newRefreshTokenHash, tokenProvider.getRefreshTokenExpiryTime(), socialAuth.getDeviceInfo()); return AuthResponseDTO.TokenInfo.builder() .accessToken(newAccessToken) From 4dd0fa79881d7176568aabc0270b83a04f86ca55 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:03:03 +0900 Subject: [PATCH 23/51] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EB=8B=A8?= =?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/auth/service/AuthServiceTest.java | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java b/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java index 074e85ed..e4d093ec 100644 --- a/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java @@ -63,7 +63,7 @@ void socialLogin_newUser_returnsIsNewUserTrue() { given(tokenProvider.getAccessTokenExpirationSeconds()).willReturn(3600L); SocialAuth savedSocialAuth = SocialAuth.create( - newUser, SocialType.KAKAO, "12345", "new_refresh_token", "token_hash", LocalDateTime.now().plusDays(7), "deviceInfo" + newUser, SocialType.KAKAO, "12345", "token_hash", LocalDateTime.now().plusDays(7), "deviceInfo" ); given(socialAuthRepository.save(any(SocialAuth.class))).willReturn(savedSocialAuth); @@ -84,7 +84,7 @@ void socialLogin_existingUser_returnsIsNewUserFalse() { User existingUser = User.createFromOAuth(userInfo.profileImgUrl()); SocialAuth existingSocialAuth = SocialAuth.create( - existingUser, SocialType.KAKAO, "12345", "old_token", "old_hash", LocalDateTime.now().plusDays(7), "deviceInfo" + existingUser, SocialType.KAKAO, "12345", "old_hash", LocalDateTime.now().plusDays(7), "deviceInfo" ); given(socialAuthRepository.findBySocialTypeAndSocialId(SocialType.KAKAO, "12345")) .willReturn(Optional.of(existingSocialAuth)); @@ -108,8 +108,8 @@ void logout_expiresAllSocialAuthTokens() { // given Long userId = 1L; User user = User.createFromOAuth("https://example.com/profile.png"); - SocialAuth kakaoAuth = SocialAuth.create(user, SocialType.KAKAO, "k123", "t1", "h1", LocalDateTime.now().plusDays(1), "device"); - SocialAuth googleAuth = SocialAuth.create(user, SocialType.GOOGLE, "g456", "t2", "h2", LocalDateTime.now().plusDays(1), "device"); + SocialAuth kakaoAuth = SocialAuth.create(user, SocialType.KAKAO, "k123", "h1", LocalDateTime.now().plusDays(1), "device"); + SocialAuth googleAuth = SocialAuth.create(user, SocialType.GOOGLE, "g456", "h2", LocalDateTime.now().plusDays(1), "device"); given(socialAuthRepository.findAllByUser_UserId(userId)).willReturn(List.of(kakaoAuth, googleAuth)); @@ -126,7 +126,7 @@ void withdraw_deletesAllSocialAuthsAndUser() { // given Long userId = 1L; User user = User.createFromOAuth("https://example.com/profile.png"); - SocialAuth kakaoAuth = SocialAuth.create(user, SocialType.KAKAO, "k123", "t1", "h1", LocalDateTime.now().plusDays(1), "device"); + SocialAuth kakaoAuth = SocialAuth.create(user, SocialType.KAKAO, "k123", "h1", LocalDateTime.now().plusDays(1), "device"); given(userRepository.findById(userId)).willReturn(Optional.of(user)); given(socialAuthRepository.findAllByUser_UserId(userId)).willReturn(List.of(kakaoAuth)); From cdc38d5c932d41d2c9f8eed4c5147dbd71be6a6d Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:08:36 +0900 Subject: [PATCH 24/51] =?UTF-8?q?feat:=20=EB=8F=99=EC=8B=9C=EC=84=B1=20?= =?UTF-8?q?=EB=9D=BD=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/repository/SocialAuthRepository.java | 11 +++++++++-- .../java/com/mr/domain/auth/service/AuthService.java | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java b/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java index 276925a7..dbf5ec26 100644 --- a/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java +++ b/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java @@ -2,10 +2,13 @@ import com.mr.domain.auth.entity.SocialAuth; import com.mr.domain.auth.entity.enums.SocialType; -import org.springframework.data.jpa.repository.JpaRepository; - +import jakarta.persistence.LockModeType; import java.util.List; import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface SocialAuthRepository extends JpaRepository { @@ -13,4 +16,8 @@ public interface SocialAuthRepository extends JpaRepository { Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId); Optional findByUser_UserIdAndSocialType(Long userId, SocialType socialType); Optional findByRefreshTokenHash(String refreshTokenHash); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT s FROM SocialAuth s WHERE s.refreshTokenHash = :refreshTokenHash") + Optional findByRefreshTokenHashWithLock(@Param("refreshTokenHash") String refreshTokenHash); } \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 2cfe1fa1..63b1f0f6 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -94,7 +94,7 @@ public AuthResponseDTO.TokenInfo reissueToken(String refreshToken) { Long userId = Long.valueOf(tokenProvider.getAuthentication(refreshToken).getName()); String requestTokenHash = tokenProvider.hashToken(refreshToken); - SocialAuth socialAuth = socialAuthRepository.findByRefreshTokenHash(requestTokenHash) + SocialAuth socialAuth = socialAuthRepository.findByRefreshTokenHashWithLock(requestTokenHash) .filter(auth -> auth.getUser().getUserId().equals(userId)) .orElseThrow(() -> new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST)); From 7c465ca2005122e9b47f3b7691b8c69277bad60f Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:09:06 +0900 Subject: [PATCH 25/51] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=EC=8B=9C=EA=B0=84=20=EB=B3=B4=EC=9E=A5=20?= =?UTF-8?q?(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthServiceTest.java | 144 +++++++++--------- 1 file changed, 72 insertions(+), 72 deletions(-) diff --git a/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java b/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java index e4d093ec..e8d781ed 100644 --- a/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java @@ -1,9 +1,7 @@ package com.mr.domain.auth.service; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; -import static org.mockito.Mockito.verify; import com.mr.domain.auth.dto.OAuthUserInfo; import com.mr.domain.auth.dto.res.AuthResponseDTO; @@ -13,87 +11,69 @@ import com.mr.domain.user.entity.User; import com.mr.domain.user.repository.UserRepository; import com.mr.global.security.jwt.JwtTokenProvider; -import java.time.LocalDateTime; import java.util.List; -import java.util.Optional; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.transaction.annotation.Transactional; -@ExtendWith(MockitoExtension.class) +@SpringBootTest +@Transactional class AuthServiceTest { - @Mock - private SocialAuthRepository socialAuthRepository; - @Mock + @Autowired + private AuthService authService; + + @Autowired private UserRepository userRepository; - @Mock + + @Autowired + private SocialAuthRepository socialAuthRepository; + + @Autowired private JwtTokenProvider tokenProvider; - @Mock - private OAuthClientService oAuthClientService; - @InjectMocks - private AuthService authService; + @MockBean + private OAuthClientService oAuthClientService; - private OAuthUserInfo userInfo; + private OAuthUserInfo kakaoUserInfo; @BeforeEach void setUp() { - userInfo = new OAuthUserInfo("12345", "https://example.com/profile.png"); + kakaoUserInfo = new OAuthUserInfo("12345", "https://example.com/profile.png"); } @Test - @DisplayName("socialLogin - 최초 로그인 시 isNewUser=true로 반환된다") - void socialLogin_newUser_returnsIsNewUserTrue() { + @DisplayName("socialLogin - 최초 소셜 로그인 시 DB에 User와 SocialAuth가 생성되고 isNewUser=true로 반환된다") + void socialLogin_newUser_savesToDbAndReturnsIsNewUserTrue() { // given - given(oAuthClientService.getUserInfo(SocialType.KAKAO, "access_token")).willReturn(userInfo); - given(socialAuthRepository.findBySocialTypeAndSocialId(SocialType.KAKAO, "12345")) - .willReturn(Optional.empty()); - - User newUser = User.createFromOAuth(userInfo.profileImgUrl()); - given(userRepository.save(any(User.class))).willReturn(newUser); - given(tokenProvider.createAccessToken(any())).willReturn("new_access_token"); - given(tokenProvider.createRefreshToken(any())).willReturn("new_refresh_token"); - given(tokenProvider.hashToken(any())).willReturn("token_hash"); - given(tokenProvider.getRefreshTokenExpiryTime()).willReturn(LocalDateTime.now().plusDays(7)); - given(tokenProvider.getAccessTokenExpirationSeconds()).willReturn(3600L); - - SocialAuth savedSocialAuth = SocialAuth.create( - newUser, SocialType.KAKAO, "12345", "token_hash", LocalDateTime.now().plusDays(7), "deviceInfo" - ); - given(socialAuthRepository.save(any(SocialAuth.class))).willReturn(savedSocialAuth); + given(oAuthClientService.getUserInfo(SocialType.KAKAO, "access_token")).willReturn(kakaoUserInfo); // when AuthResponseDTO.LoginResponse response = authService.socialLogin(SocialType.KAKAO, "access_token", "deviceInfo"); // then assertThat(response.isNewUser()).isTrue(); - verify(userRepository).save(any(User.class)); - verify(socialAuthRepository).save(any(SocialAuth.class)); + assertThat(response.userId()).isNotNull(); + + User savedUser = userRepository.findById(response.userId()).orElse(null); + assertThat(savedUser).isNotNull(); + + List socialAuths = socialAuthRepository.findAllByUser_UserId(response.userId()); + assertThat(socialAuths).hasSize(1); + assertThat(socialAuths.get(0).getSocialId()).isEqualTo("12345"); + assertThat(socialAuths.get(0).getSocialType()).isEqualTo(SocialType.KAKAO); } @Test - @DisplayName("socialLogin - 기존 회원 로그인 시 isNewUser=false로 반환된다") + @DisplayName("socialLogin - 이미 존재하는 계정으로 로그인 시 isNewUser=false로 반환된다") void socialLogin_existingUser_returnsIsNewUserFalse() { // given - given(oAuthClientService.getUserInfo(SocialType.KAKAO, "access_token")).willReturn(userInfo); - - User existingUser = User.createFromOAuth(userInfo.profileImgUrl()); - SocialAuth existingSocialAuth = SocialAuth.create( - existingUser, SocialType.KAKAO, "12345", "old_hash", LocalDateTime.now().plusDays(7), "deviceInfo" - ); - given(socialAuthRepository.findBySocialTypeAndSocialId(SocialType.KAKAO, "12345")) - .willReturn(Optional.of(existingSocialAuth)); - - given(tokenProvider.createAccessToken(any())).willReturn("new_access_token"); - given(tokenProvider.createRefreshToken(any())).willReturn("new_refresh_token"); - given(tokenProvider.hashToken(any())).willReturn("token_hash"); - given(tokenProvider.getRefreshTokenExpiryTime()).willReturn(LocalDateTime.now().plusDays(7)); - given(tokenProvider.getAccessTokenExpirationSeconds()).willReturn(3600L); + given(oAuthClientService.getUserInfo(SocialType.KAKAO, "access_token")).willReturn(kakaoUserInfo); + authService.socialLogin(SocialType.KAKAO, "access_token", "deviceInfo"); // 최초 회원가입 // when AuthResponseDTO.LoginResponse response = authService.socialLogin(SocialType.KAKAO, "access_token", "deviceInfo"); @@ -103,39 +83,59 @@ void socialLogin_existingUser_returnsIsNewUserFalse() { } @Test - @DisplayName("logout - 사용자의 모든 SocialAuth 토큰을 만료 처리한다") - void logout_expiresAllSocialAuthTokens() { + @DisplayName("reissueToken - 발급된 실제 Refresh Token으로 토큰 재발급 요청 시 새로운 토큰 세트가 발급되고 DB 해시가 업데이트된다") + void reissueToken_realToken_reissuesTokensAndUpdateDbHash() throws InterruptedException { // given - Long userId = 1L; - User user = User.createFromOAuth("https://example.com/profile.png"); - SocialAuth kakaoAuth = SocialAuth.create(user, SocialType.KAKAO, "k123", "h1", LocalDateTime.now().plusDays(1), "device"); - SocialAuth googleAuth = SocialAuth.create(user, SocialType.GOOGLE, "g456", "h2", LocalDateTime.now().plusDays(1), "device"); + given(oAuthClientService.getUserInfo(SocialType.KAKAO, "access_token")).willReturn(kakaoUserInfo); + AuthResponseDTO.LoginResponse loginResponse = authService.socialLogin(SocialType.KAKAO, "access_token", "deviceInfo"); + String originalRefreshToken = loginResponse.tokenInfo().refreshToken(); + String originalHash = tokenProvider.hashToken(originalRefreshToken); - given(socialAuthRepository.findAllByUser_UserId(userId)).willReturn(List.of(kakaoAuth, googleAuth)); + Thread.sleep(1005); // JWT issuedAt(초 단위) 타임스탬프 차이 보장 // when - authService.logout(userId); + AuthResponseDTO.TokenInfo reissuedTokenInfo = authService.reissueToken(originalRefreshToken); // then - verify(socialAuthRepository).findAllByUser_UserId(userId); + assertThat(reissuedTokenInfo.accessToken()).isNotBlank(); + assertThat(reissuedTokenInfo.refreshToken()).isNotBlank(); + assertThat(reissuedTokenInfo.refreshToken()).isNotEqualTo(originalRefreshToken); + + SocialAuth socialAuth = socialAuthRepository.findAllByUser_UserId(loginResponse.userId()).get(0); + assertThat(socialAuth.getRefreshTokenHash()).isEqualTo(tokenProvider.hashToken(reissuedTokenInfo.refreshToken())); + assertThat(socialAuth.getRefreshTokenHash()).isNotEqualTo(originalHash); } @Test - @DisplayName("withdraw - 사용자의 모든 SocialAuth를 삭제하고 User를 삭제한다") - void withdraw_deletesAllSocialAuthsAndUser() { + @DisplayName("logout - 로그아웃 시 사용자의 모든 SocialAuth 토큰 해시가 초기화(만료)된다") + void logout_expiresAllSocialAuthTokensInDb() { // given - Long userId = 1L; - User user = User.createFromOAuth("https://example.com/profile.png"); - SocialAuth kakaoAuth = SocialAuth.create(user, SocialType.KAKAO, "k123", "h1", LocalDateTime.now().plusDays(1), "device"); + given(oAuthClientService.getUserInfo(SocialType.KAKAO, "access_token")).willReturn(kakaoUserInfo); + AuthResponseDTO.LoginResponse loginResponse = authService.socialLogin(SocialType.KAKAO, "access_token", "deviceInfo"); + Long userId = loginResponse.userId(); + + // when + authService.logout(userId); - given(userRepository.findById(userId)).willReturn(Optional.of(user)); - given(socialAuthRepository.findAllByUser_UserId(userId)).willReturn(List.of(kakaoAuth)); + // then + List socialAuths = socialAuthRepository.findAllByUser_UserId(userId); + assertThat(socialAuths).hasSize(1); + assertThat(socialAuths.get(0).getRefreshTokenHash()).isNull(); + } + + @Test + @DisplayName("withdraw - 회원 탈퇴 시 DB에서 SocialAuth 및 User가 완전히 삭제된다") + void withdraw_deletesSocialAuthAndUserFromDb() { + // given + given(oAuthClientService.getUserInfo(SocialType.KAKAO, "access_token")).willReturn(kakaoUserInfo); + AuthResponseDTO.LoginResponse loginResponse = authService.socialLogin(SocialType.KAKAO, "access_token", "deviceInfo"); + Long userId = loginResponse.userId(); // when authService.withdraw(userId); // then - verify(socialAuthRepository).deleteAll(List.of(kakaoAuth)); - verify(userRepository).delete(user); + assertThat(userRepository.findById(userId)).isEmpty(); + assertThat(socialAuthRepository.findAllByUser_UserId(userId)).isEmpty(); } } From bd6d5195b0c9f3a95f692d86d7bc447f5416c941 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:11:20 +0900 Subject: [PATCH 26/51] =?UTF-8?q?feat:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=86=A0=ED=81=B0=20=EB=B0=9C=EA=B8=89=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/auth/service/AuthService.java | 48 +++++++++++-------- 1 file changed, 29 insertions(+), 19 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 63b1f0f6..d514c20e 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -16,6 +16,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -38,14 +39,37 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String a Optional optionalSocialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId()); boolean isNewUser = optionalSocialAuth.isEmpty(); - SocialAuth socialAuth = optionalSocialAuth.orElseGet(() -> registerNewUser(socialType, userInfo, deviceInfo)); - User user = socialAuth.getUser(); + User user; + SocialAuth socialAuth; + + if (optionalSocialAuth.isPresent()) { + socialAuth = optionalSocialAuth.get(); + user = socialAuth.getUser(); + } else { + user = registerNewUser(userInfo); + socialAuth = null; + } + String newAccessToken = tokenProvider.createAccessToken(user.getUserId()); String newRefreshToken = tokenProvider.createRefreshToken(user.getUserId()); String refreshTokenHash = tokenProvider.hashToken(newRefreshToken); + LocalDateTime expiryTime = tokenProvider.getRefreshTokenExpiryTime(); + + if (isNewUser) { + socialAuth = SocialAuth.create( + user, + socialType, + userInfo.socialId(), + refreshTokenHash, + expiryTime, + deviceInfo + ); + socialAuth = socialAuthRepository.save(socialAuth); + } else { + socialAuth.updateRefreshToken(refreshTokenHash, expiryTime, deviceInfo); + } - socialAuth.updateRefreshToken(refreshTokenHash, tokenProvider.getRefreshTokenExpiryTime(), deviceInfo); AuthResponseDTO.TokenResponse tokenResponse = AuthResponseDTO.TokenResponse.builder() .accessToken(newAccessToken) .refreshToken(newRefreshToken) @@ -61,28 +85,14 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String a .build(); } - private SocialAuth registerNewUser(SocialType socialType, OAuthUserInfo userInfo, String deviceInfo) { + private User registerNewUser(OAuthUserInfo userInfo) { String profileImgUrl = userInfo.profileImgUrl(); if (profileImgUrl == null || profileImgUrl.isBlank()) { profileImgUrl = defaultProfileImageUrl; } User user = User.createFromOAuth(profileImgUrl); - userRepository.save(user); - - String initialToken = tokenProvider.createRefreshToken(user.getUserId()); - String initialHash = tokenProvider.hashToken(initialToken); - - SocialAuth socialAuth = SocialAuth.create( - user, - socialType, - userInfo.socialId(), - initialHash, - tokenProvider.getRefreshTokenExpiryTime(), - deviceInfo - ); - - return socialAuthRepository.save(socialAuth); + return userRepository.save(user); } @Transactional From 64b49f9c41fa827a76251ed01022ef0261a740b5 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:14:20 +0900 Subject: [PATCH 27/51] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=20=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9E=90=20=EC=A0=95=EB=B3=B4=20=EC=9A=94=EC=B2=AD?= =?UTF-8?q?=EC=9D=80=20=ED=8A=B8=EB=9E=9C=EC=A0=9D=EC=85=95=20=EC=99=B8?= =?UTF-8?q?=EB=B6=80=EB=A1=9C=20=EC=9D=B4=EB=8F=99(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/auth/service/AuthService.java | 97 ++++++++++--------- 1 file changed, 50 insertions(+), 47 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index d514c20e..189d7874 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -15,6 +15,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; import java.time.LocalDateTime; import java.util.List; @@ -29,60 +30,62 @@ public class AuthService { private final UserRepository userRepository; private final JwtTokenProvider tokenProvider; private final OAuthClientService oAuthClientService; + private final TransactionTemplate transactionTemplate; @Value("${app.profile.default-image-url}") private String defaultProfileImageUrl; - @Transactional public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken, String deviceInfo) { OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, accessToken); - Optional optionalSocialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId()); - boolean isNewUser = optionalSocialAuth.isEmpty(); - - User user; - SocialAuth socialAuth; - - if (optionalSocialAuth.isPresent()) { - socialAuth = optionalSocialAuth.get(); - user = socialAuth.getUser(); - } else { - user = registerNewUser(userInfo); - socialAuth = null; - } - - String newAccessToken = tokenProvider.createAccessToken(user.getUserId()); - String newRefreshToken = tokenProvider.createRefreshToken(user.getUserId()); - String refreshTokenHash = tokenProvider.hashToken(newRefreshToken); - LocalDateTime expiryTime = tokenProvider.getRefreshTokenExpiryTime(); - - if (isNewUser) { - socialAuth = SocialAuth.create( - user, - socialType, - userInfo.socialId(), - refreshTokenHash, - expiryTime, - deviceInfo - ); - socialAuth = socialAuthRepository.save(socialAuth); - } else { - socialAuth.updateRefreshToken(refreshTokenHash, expiryTime, deviceInfo); - } - - AuthResponseDTO.TokenResponse tokenResponse = AuthResponseDTO.TokenResponse.builder() - .accessToken(newAccessToken) - .refreshToken(newRefreshToken) - .accessTokenExpiresInSeconds(tokenProvider.getAccessTokenExpirationSeconds()) - .build(); - - return AuthResponseDTO.LoginResponse.builder() - .userId(user.getUserId()) - .nickname(user.getNickname()) - .isNewUser(isNewUser) - .isOnboardingCompleted(user.isOnboardingCompleted()) - .tokenInfo(tokenResponse) - .build(); + return transactionTemplate.execute(status -> { + Optional optionalSocialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId()); + boolean isNewUser = optionalSocialAuth.isEmpty(); + + User user; + SocialAuth socialAuth; + + if (optionalSocialAuth.isPresent()) { + socialAuth = optionalSocialAuth.get(); + user = socialAuth.getUser(); + } else { + user = registerNewUser(userInfo); + socialAuth = null; + } + + String newAccessToken = tokenProvider.createAccessToken(user.getUserId()); + String newRefreshToken = tokenProvider.createRefreshToken(user.getUserId()); + String refreshTokenHash = tokenProvider.hashToken(newRefreshToken); + LocalDateTime expiryTime = tokenProvider.getRefreshTokenExpiryTime(); + + if (isNewUser) { + socialAuth = SocialAuth.create( + user, + socialType, + userInfo.socialId(), + refreshTokenHash, + expiryTime, + deviceInfo + ); + socialAuth = socialAuthRepository.save(socialAuth); + } else { + socialAuth.updateRefreshToken(refreshTokenHash, expiryTime, deviceInfo); + } + + AuthResponseDTO.TokenResponse tokenResponse = AuthResponseDTO.TokenResponse.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .accessTokenExpiresInSeconds(tokenProvider.getAccessTokenExpirationSeconds()) + .build(); + + return AuthResponseDTO.LoginResponse.builder() + .userId(user.getUserId()) + .nickname(user.getNickname()) + .isNewUser(isNewUser) + .isOnboardingCompleted(user.isOnboardingCompleted()) + .tokenInfo(tokenResponse) + .build(); + }); } private User registerNewUser(OAuthUserInfo userInfo) { From 9ab33fff370e6276817a5306c09c8bfdaf95587d Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:15:56 +0900 Subject: [PATCH 28/51] =?UTF-8?q?feat:=20=EB=8F=99=EC=8B=9C=20=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=9A=94=EC=B2=AD=EC=8B=9C=20=EC=A0=9C=EC=95=BD?= =?UTF-8?q?=EC=A1=B0=EA=B1=B4,=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/auth/service/AuthService.java | 42 ++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 189d7874..0187ff75 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -13,6 +13,7 @@ import com.mr.global.security.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; @@ -38,6 +39,15 @@ public class AuthService { public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken, String deviceInfo) { OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, accessToken); + try { + return executeSocialLogin(socialType, userInfo, deviceInfo); + } catch (DataIntegrityViolationException e) { + // 동시 가입 요청으로 DB Unique 제약조건(uk_social_auth_type_id) 위반 시, 기존 가입된 계정으로 로그인 재시도 + return executeSocialLoginForExistingUser(socialType, userInfo, deviceInfo); + } + } + + private AuthResponseDTO.LoginResponse executeSocialLogin(SocialType socialType, OAuthUserInfo userInfo, String deviceInfo) { return transactionTemplate.execute(status -> { Optional optionalSocialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId()); boolean isNewUser = optionalSocialAuth.isEmpty(); @@ -67,7 +77,7 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String a expiryTime, deviceInfo ); - socialAuth = socialAuthRepository.save(socialAuth); + socialAuthRepository.saveAndFlush(socialAuth); } else { socialAuth.updateRefreshToken(refreshTokenHash, expiryTime, deviceInfo); } @@ -88,6 +98,36 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String a }); } + private AuthResponseDTO.LoginResponse executeSocialLoginForExistingUser(SocialType socialType, OAuthUserInfo userInfo, String deviceInfo) { + return transactionTemplate.execute(status -> { + SocialAuth socialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId()) + .orElseThrow(() -> new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST)); + + User user = socialAuth.getUser(); + + String newAccessToken = tokenProvider.createAccessToken(user.getUserId()); + String newRefreshToken = tokenProvider.createRefreshToken(user.getUserId()); + String refreshTokenHash = tokenProvider.hashToken(newRefreshToken); + LocalDateTime expiryTime = tokenProvider.getRefreshTokenExpiryTime(); + + socialAuth.updateRefreshToken(refreshTokenHash, expiryTime, deviceInfo); + + AuthResponseDTO.TokenResponse tokenResponse = AuthResponseDTO.TokenResponse.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .accessTokenExpiresInSeconds(tokenProvider.getAccessTokenExpirationSeconds()) + .build(); + + return AuthResponseDTO.LoginResponse.builder() + .userId(user.getUserId()) + .nickname(user.getNickname()) + .isNewUser(false) + .isOnboardingCompleted(user.isOnboardingCompleted()) + .tokenInfo(tokenResponse) + .build(); + }); + } + private User registerNewUser(OAuthUserInfo userInfo) { String profileImgUrl = userInfo.profileImgUrl(); if (profileImgUrl == null || profileImgUrl.isBlank()) { From 634bccb062fbd811f8a8333bf815009120877b54 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:18:31 +0900 Subject: [PATCH 29/51] =?UTF-8?q?feat:=20=EB=AC=B8=EC=84=9C=20=EC=9E=AC?= =?UTF-8?q?=EC=A0=95=EC=9D=98=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/auth/controller/AuthController.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/controller/AuthController.java b/src/main/java/com/mr/domain/auth/controller/AuthController.java index 307213e9..bf7a592c 100644 --- a/src/main/java/com/mr/domain/auth/controller/AuthController.java +++ b/src/main/java/com/mr/domain/auth/controller/AuthController.java @@ -52,7 +52,10 @@ public ApiResponse reissue( return ApiResponse.onSuccess(tokenInfo); } - @Operation(summary = "로그아웃 API", description = "현재 로그인된 사용자의 세션 및 토큰을 무효화합니다.") + @Operation( + summary = "로그아웃 API", + description = "현재 로그인된 사용자의 Refresh Token 세션을 만료 처리합니다. (발급된 Access Token은 자체 만료 시각까지 유효하며, 추가 토큰 재발급이 차단됩니다.)" + ) @PostMapping("/logout") public ApiResponse logout( @Parameter(hidden = true) @RequestAttribute("userId") Long userId @@ -61,7 +64,10 @@ public ApiResponse logout( return ApiResponse.onSuccess(null); } - @Operation(summary = "회원 탈퇴 API", description = "사용자 계정을 탈퇴 처리하고 관련 인증 정보를 삭제/만료합니다.") + @Operation( + summary = "회원 탈퇴 API", + description = "사용자 계정을 탈퇴 처리하고 저장된 소셜 인증 정보 및 Refresh Token 세션을 완전히 삭제합니다." + ) @PostMapping("/withdraw") public ApiResponse withdraw( @Parameter(hidden = true) @RequestAttribute("userId") Long userId From d6ba3c04f67f226980c57b3984f516bb215a962e Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:22:37 +0900 Subject: [PATCH 30/51] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=EC=8B=9C=20=ED=95=B4=EB=8B=B9=20=EA=B8=B0=EA=B8=B0?= =?UTF-8?q?=EC=97=90=20=ED=95=9C=ED=95=A8=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/auth/controller/AuthController.java | 7 ++++--- .../mr/domain/auth/dto/req/AuthRequestDTO.java | 5 +++++ .../com/mr/domain/auth/service/AuthService.java | 15 ++++++++++----- .../mr/domain/auth/service/AuthServiceTest.java | 7 ++++--- 4 files changed, 23 insertions(+), 11 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/controller/AuthController.java b/src/main/java/com/mr/domain/auth/controller/AuthController.java index bf7a592c..2e9cf8bc 100644 --- a/src/main/java/com/mr/domain/auth/controller/AuthController.java +++ b/src/main/java/com/mr/domain/auth/controller/AuthController.java @@ -54,13 +54,14 @@ public ApiResponse reissue( @Operation( summary = "로그아웃 API", - description = "현재 로그인된 사용자의 Refresh Token 세션을 만료 처리합니다. (발급된 Access Token은 자체 만료 시각까지 유효하며, 추가 토큰 재발급이 차단됩니다.)" + description = "현재 요청 기기의 Refresh Token 세션을 선택적으로 만료 처리합니다." ) @PostMapping("/logout") public ApiResponse logout( - @Parameter(hidden = true) @RequestAttribute("userId") Long userId + @Parameter(hidden = true) @RequestAttribute("userId") Long userId, + @RequestBody @Valid AuthRequestDTO.LogoutRequest request ) { - authService.logout(userId); + authService.logout(userId, request.refreshToken()); return ApiResponse.onSuccess(null); } diff --git a/src/main/java/com/mr/domain/auth/dto/req/AuthRequestDTO.java b/src/main/java/com/mr/domain/auth/dto/req/AuthRequestDTO.java index 573ab64f..888d3870 100644 --- a/src/main/java/com/mr/domain/auth/dto/req/AuthRequestDTO.java +++ b/src/main/java/com/mr/domain/auth/dto/req/AuthRequestDTO.java @@ -13,4 +13,9 @@ public record TokenRefreshRequest( @NotBlank(message = "Refresh Token은 필수 입력값입니다.") String refreshToken ) {} + + public record LogoutRequest( + @NotBlank(message = "Refresh Token은 필수 입력값입니다.") + String refreshToken + ) {} } \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 0187ff75..e14b04f4 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -165,13 +165,18 @@ public AuthResponseDTO.TokenInfo reissueToken(String refreshToken) { } @Transactional - public void logout(Long userId) { - List socialAuths = socialAuthRepository.findAllByUser_UserId(userId); - if (socialAuths.isEmpty()) { - throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + public void logout(Long userId, String refreshToken) { + if (!tokenProvider.validateRefreshToken(refreshToken)) { + throw new GeneralException(AuthErrorStatus.INVALID_TOKEN); } - socialAuths.forEach(SocialAuth::expireToken); + String requestTokenHash = tokenProvider.hashToken(refreshToken); + + SocialAuth socialAuth = socialAuthRepository.findByRefreshTokenHashWithLock(requestTokenHash) + .filter(auth -> auth.getUser().getUserId().equals(userId)) + .orElseThrow(() -> new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST)); + + socialAuth.expireToken(); } @Transactional diff --git a/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java b/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java index e8d781ed..1683768a 100644 --- a/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java @@ -107,15 +107,16 @@ void reissueToken_realToken_reissuesTokensAndUpdateDbHash() throws InterruptedEx } @Test - @DisplayName("logout - 로그아웃 시 사용자의 모든 SocialAuth 토큰 해시가 초기화(만료)된다") - void logout_expiresAllSocialAuthTokensInDb() { + @DisplayName("logout - 요청된 특정 Refresh Token 세션만 선택적으로 만료 처리한다") + void logout_expiresSpecificDeviceSession() { // given given(oAuthClientService.getUserInfo(SocialType.KAKAO, "access_token")).willReturn(kakaoUserInfo); AuthResponseDTO.LoginResponse loginResponse = authService.socialLogin(SocialType.KAKAO, "access_token", "deviceInfo"); Long userId = loginResponse.userId(); + String refreshToken = loginResponse.tokenInfo().refreshToken(); // when - authService.logout(userId); + authService.logout(userId, refreshToken); // then List socialAuths = socialAuthRepository.findAllByUser_UserId(userId); From 12aad782398d8dca86ffb273b04fbe673c8835a5 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:27:42 +0900 Subject: [PATCH 31/51] =?UTF-8?q?feat:=20=EC=83=81=ED=83=9C,=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC=EC=BD=94=EB=93=9C=20=EA=B5=AC=EB=B6=84=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/exception/AuthErrorStatus.java | 8 ++- .../auth/service/OAuthClientService.java | 49 ++++++++++++++----- 2 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/exception/AuthErrorStatus.java b/src/main/java/com/mr/domain/auth/exception/AuthErrorStatus.java index 5555c50b..eaa3e681 100644 --- a/src/main/java/com/mr/domain/auth/exception/AuthErrorStatus.java +++ b/src/main/java/com/mr/domain/auth/exception/AuthErrorStatus.java @@ -22,8 +22,12 @@ public enum AuthErrorStatus implements BaseCode { // 리소스 부재 SOCIAL_AUTH_NOT_FOUND(HttpStatus.NOT_FOUND, "AUTH_404_01", "해당 사용자의 소셜 인증 기록을 찾을 수 없습니다."), - // 데이터 무결성ㅇ - ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, "AUTH_409_01", "이미 다른 계정에 연동되어 있는 소셜 계정입니다."); + // 데이터 무결성 + ALREADY_LINKED_SOCIAL_ACCOUNT(HttpStatus.CONFLICT, "AUTH_409_01", "이미 다른 계정에 연동되어 있는 소셜 계정입니다."), + + // 외부 소셜 연동 오류 + OAUTH_CLIENT_ERROR(HttpStatus.UNAUTHORIZED, "AUTH_401_04", "소셜 로그인 인증에 실패했거나 유효하지 않은 소셜 액세스 토큰입니다."), + OAUTH_SERVER_ERROR(HttpStatus.SERVICE_UNAVAILABLE, "AUTH_503_01", "소셜 인증 제공자(카카오/구글) 서버와의 통신에 실패했습니다."); private final HttpStatus status; private final String code; diff --git a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java index bf902249..6ab04f2b 100644 --- a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java +++ b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java @@ -2,8 +2,9 @@ import com.mr.domain.auth.dto.OAuthUserInfo; import com.mr.domain.auth.entity.enums.SocialType; -import com.mr.global.apipayload.code.CommonStatus; +import com.mr.domain.auth.exception.AuthErrorStatus; import com.mr.global.apipayload.exception.GeneralException; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.client.ClientHttpRequestFactories; import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; @@ -11,8 +12,10 @@ import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestClient; -import lombok.extern.slf4j.Slf4j; +import org.springframework.web.client.RestClientResponseException; import java.time.Duration; import java.util.Map; @@ -70,10 +73,8 @@ private OAuthUserInfo getKakaoUserInfo(String accessToken) { .profileImgUrl(profileImgUrl) .build(); - } catch (GeneralException e) { - throw e; } catch (Exception e) { - throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); + throw mapOAuthException(e, "Kakao"); } } @@ -93,25 +94,51 @@ private OAuthUserInfo getGoogleUserInfo(String accessToken) { .socialId(socialId) .profileImgUrl(profileImgUrl) .build(); - } catch (GeneralException e) { - throw e; } catch (Exception e) { - throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); + throw mapOAuthException(e, "Google"); } } private String extractSocialId(Map response) { if (response == null) { - throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); } Object idObj = response.get("id"); if (idObj == null) { - throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); } String socialId = String.valueOf(idObj); if (socialId.isBlank() || "null".equalsIgnoreCase(socialId.trim())) { - throw new GeneralException(CommonStatus.INVALID_INPUT_VALUE); + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); } return socialId; } + + private GeneralException mapOAuthException(Exception e, String provider) { + if (e instanceof GeneralException ge) { + return ge; + } + if (e instanceof HttpClientErrorException clientErr) { + log.warn("{} OAuth 클라이언트 인증 오류 (status={}): {}", provider, clientErr.getStatusCode(), clientErr.getMessage()); + return new GeneralException(AuthErrorStatus.OAUTH_CLIENT_ERROR); + } + if (e instanceof HttpServerErrorException serverErr) { + log.error("{} OAuth 서버 오류 (status={}): {}", provider, serverErr.getStatusCode(), serverErr.getMessage()); + return new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR); + } + if (e instanceof ResourceAccessException netErr) { + log.error("{} OAuth 타임아웃/네트워크 통신 오류: {}", provider, netErr.getMessage()); + return new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR); + } + if (e instanceof RestClientResponseException rcre) { + log.error("{} OAuth HTTP 응답 예외 (status={}): {}", provider, rcre.getStatusCode(), rcre.getMessage()); + if (rcre.getStatusCode().is4xxClientError()) { + return new GeneralException(AuthErrorStatus.OAUTH_CLIENT_ERROR); + } else { + return new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR); + } + } + log.error("{} OAuth 사용자 정보 처리 중 예외 발생", provider, e); + return new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } } \ No newline at end of file From 029a1eef015bbe17c90d3f3d6c0b8392415c4baf Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:27:58 +0900 Subject: [PATCH 32/51] =?UTF-8?q?feat:=20401,=20500=20=EA=B2=80=EC=A6=9D?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/OAuthClientServiceTest.java | 34 ++++++++++++++++--- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java index 76a19c1b..b200a6eb 100644 --- a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import com.mr.domain.auth.entity.enums.SocialType; +import com.mr.domain.auth.exception.AuthErrorStatus; import com.mr.global.apipayload.exception.GeneralException; import java.time.Duration; import org.junit.jupiter.api.BeforeEach; @@ -40,13 +41,14 @@ void createOAuthClientService_withTimeout() { } @Test - @DisplayName("카카오 response의 id가 null이면 GeneralException이 발생한다") + @DisplayName("카카오 response의 id가 null이면 GeneralException(INVALID_AUTH_REQUEST)이 발생한다") void getKakaoUserInfo_nullId_throwsException() { mockServer.expect(MockRestRequestMatchers.requestTo("https://kapi.kakao.com/v2/user/me")) .andRespond(MockRestResponseCreators.withSuccess("{\"id\": null}", MediaType.APPLICATION_JSON)); assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.KAKAO, "token")) - .isInstanceOf(GeneralException.class); + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AuthErrorStatus.INVALID_AUTH_REQUEST)); } @Test @@ -56,7 +58,8 @@ void getKakaoUserInfo_literalNullId_throwsException() { .andRespond(MockRestResponseCreators.withSuccess("{\"id\": \"null\"}", MediaType.APPLICATION_JSON)); assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.KAKAO, "token")) - .isInstanceOf(GeneralException.class); + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AuthErrorStatus.INVALID_AUTH_REQUEST)); } @Test @@ -66,6 +69,29 @@ void getGoogleUserInfo_missingId_throwsException() { .andRespond(MockRestResponseCreators.withSuccess("{\"email\": \"test@example.com\"}", MediaType.APPLICATION_JSON)); assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.GOOGLE, "token")) - .isInstanceOf(GeneralException.class); + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AuthErrorStatus.INVALID_AUTH_REQUEST)); + } + + @Test + @DisplayName("카카오 OAuth 서버가 401 Unauthorized를 반환하면 OAUTH_CLIENT_ERROR 예외로 매핑된다") + void getKakaoUserInfo_401Error_throwsOauthClientError() { + mockServer.expect(MockRestRequestMatchers.requestTo("https://kapi.kakao.com/v2/user/me")) + .andRespond(MockRestResponseCreators.withUnauthorizedRequest()); + + assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.KAKAO, "invalid_token")) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AuthErrorStatus.OAUTH_CLIENT_ERROR)); + } + + @Test + @DisplayName("구글 OAuth 서버가 500 Internal Server Error를 반환하면 OAUTH_SERVER_ERROR 예외로 매핑된다") + void getGoogleUserInfo_500Error_throwsOauthServerError() { + mockServer.expect(MockRestRequestMatchers.requestTo("https://www.googleapis.com/oauth2/v2/userinfo")) + .andRespond(MockRestResponseCreators.withServerError()); + + assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.GOOGLE, "token")) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AuthErrorStatus.OAUTH_SERVER_ERROR)); } } From 22b11bc6ab343d60edc25b4e1426eb2a8f2528a7 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:32:14 +0900 Subject: [PATCH 33/51] =?UTF-8?q?feat:=20provider=EB=B3=84=20dto=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/res/GoogleUserResponse.java | 9 +++++++++ .../domain/auth/dto/res/KakaoUserResponse.java | 18 ++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 src/main/java/com/mr/domain/auth/dto/res/GoogleUserResponse.java create mode 100644 src/main/java/com/mr/domain/auth/dto/res/KakaoUserResponse.java diff --git a/src/main/java/com/mr/domain/auth/dto/res/GoogleUserResponse.java b/src/main/java/com/mr/domain/auth/dto/res/GoogleUserResponse.java new file mode 100644 index 00000000..b2e39991 --- /dev/null +++ b/src/main/java/com/mr/domain/auth/dto/res/GoogleUserResponse.java @@ -0,0 +1,9 @@ +package com.mr.domain.auth.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record GoogleUserResponse( + String id, + String picture, + String email +) {} diff --git a/src/main/java/com/mr/domain/auth/dto/res/KakaoUserResponse.java b/src/main/java/com/mr/domain/auth/dto/res/KakaoUserResponse.java new file mode 100644 index 00000000..7ad47f85 --- /dev/null +++ b/src/main/java/com/mr/domain/auth/dto/res/KakaoUserResponse.java @@ -0,0 +1,18 @@ +package com.mr.domain.auth.dto.res; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoUserResponse( + Long id, + @JsonProperty("kakao_account") + KakaoAccount kakaoAccount +) { + public record KakaoAccount( + Profile profile + ) {} + + public record Profile( + @JsonProperty("profile_image_url") + String profileImageUrl + ) {} +} From cf0788ff37bd835c11a9f5e403ed4f11a3c8a7aa Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:32:39 +0900 Subject: [PATCH 34/51] =?UTF-8?q?feat:=20=ED=83=80=EC=9E=85=20=EB=8C=80?= =?UTF-8?q?=EC=B2=B4=20=EC=95=88=EC=A0=95=EC=84=B1=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/OAuthClientService.java | 55 ++++++++----------- 1 file changed, 22 insertions(+), 33 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java index 6ab04f2b..d26e38ee 100644 --- a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java +++ b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java @@ -1,6 +1,8 @@ package com.mr.domain.auth.service; import com.mr.domain.auth.dto.OAuthUserInfo; +import com.mr.domain.auth.dto.res.GoogleUserResponse; +import com.mr.domain.auth.dto.res.KakaoUserResponse; import com.mr.domain.auth.entity.enums.SocialType; import com.mr.domain.auth.exception.AuthErrorStatus; import com.mr.global.apipayload.exception.GeneralException; @@ -8,7 +10,6 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.web.client.ClientHttpRequestFactories; import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; -import org.springframework.core.ParameterizedTypeReference; import org.springframework.http.HttpHeaders; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; @@ -18,7 +19,6 @@ import org.springframework.web.client.RestClientResponseException; import java.time.Duration; -import java.util.Map; @Slf4j @Service @@ -51,22 +51,26 @@ public OAuthUserInfo getUserInfo(SocialType socialType, String accessToken) { private OAuthUserInfo getKakaoUserInfo(String accessToken) { try { - Map response = restClient.get() + KakaoUserResponse response = restClient.get() .uri("https://kapi.kakao.com/v2/user/me") .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded;charset=utf-8") .retrieve() - .body(new ParameterizedTypeReference>() { - }); + .body(KakaoUserResponse.class); - String socialId = extractSocialId(response); + if (response == null || response.id() == null) { + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } - @SuppressWarnings("unchecked") - Map kakaoAccount = (Map) response.get("kakao_account"); + String socialId = String.valueOf(response.id()); + if (socialId.isBlank() || "null".equalsIgnoreCase(socialId.trim())) { + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } - @SuppressWarnings("unchecked") - Map profile = kakaoAccount != null ? (Map) kakaoAccount.get("profile") : null; - String profileImgUrl = profile != null ? (String) profile.get("profile_image_url") : null; + String profileImgUrl = null; + if (response.kakaoAccount() != null && response.kakaoAccount().profile() != null) { + profileImgUrl = response.kakaoAccount().profile().profileImageUrl(); + } return OAuthUserInfo.builder() .socialId(socialId) @@ -80,40 +84,25 @@ private OAuthUserInfo getKakaoUserInfo(String accessToken) { private OAuthUserInfo getGoogleUserInfo(String accessToken) { try { - Map response = restClient.get() + GoogleUserResponse response = restClient.get() .uri("https://www.googleapis.com/oauth2/v2/userinfo") .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) .retrieve() - .body(new ParameterizedTypeReference>() { - }); + .body(GoogleUserResponse.class); - String socialId = extractSocialId(response); - String profileImgUrl = response != null ? (String) response.get("picture") : null; + if (response == null || response.id() == null || response.id().isBlank() || "null".equalsIgnoreCase(response.id().trim())) { + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } return OAuthUserInfo.builder() - .socialId(socialId) - .profileImgUrl(profileImgUrl) + .socialId(response.id()) + .profileImgUrl(response.picture()) .build(); } catch (Exception e) { throw mapOAuthException(e, "Google"); } } - private String extractSocialId(Map response) { - if (response == null) { - throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); - } - Object idObj = response.get("id"); - if (idObj == null) { - throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); - } - String socialId = String.valueOf(idObj); - if (socialId.isBlank() || "null".equalsIgnoreCase(socialId.trim())) { - throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); - } - return socialId; - } - private GeneralException mapOAuthException(Exception e, String provider) { if (e instanceof GeneralException ge) { return ge; From 660efa3f29318aa660a6760d8aacf4281bd62f36 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:35:23 +0900 Subject: [PATCH 35/51] =?UTF-8?q?feat:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=ED=97=A4=EB=8D=94=20=EC=A0=95=EB=A6=AC=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/OAuthClientService.java | 24 ++++--------------- .../auth/service/OAuthClientServiceTest.java | 10 ++++---- 2 files changed, 11 insertions(+), 23 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java index d26e38ee..5097bf06 100644 --- a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java +++ b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java @@ -7,10 +7,9 @@ import com.mr.domain.auth.exception.AuthErrorStatus; import com.mr.global.apipayload.exception.GeneralException; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.web.client.ClientHttpRequestFactories; -import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; +import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.HttpServerErrorException; @@ -18,27 +17,13 @@ import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientResponseException; -import java.time.Duration; - @Slf4j @Service public class OAuthClientService { private final RestClient restClient; - public OAuthClientService( - @Value("${oauth.connect-timeout:3s}") Duration connectTimeout, - @Value("${oauth.read-timeout:5s}") Duration readTimeout) { - ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS - .withConnectTimeout(connectTimeout) - .withReadTimeout(readTimeout); - - this.restClient = RestClient.builder() - .requestFactory(ClientHttpRequestFactories.get(settings)) - .build(); - } - - public OAuthClientService(RestClient restClient) { + public OAuthClientService(@Qualifier("oauthRestClient") RestClient restClient) { this.restClient = restClient; } @@ -54,7 +39,7 @@ private OAuthUserInfo getKakaoUserInfo(String accessToken) { KakaoUserResponse response = restClient.get() .uri("https://kapi.kakao.com/v2/user/me") .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) - .header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded;charset=utf-8") + .accept(MediaType.APPLICATION_JSON) .retrieve() .body(KakaoUserResponse.class); @@ -87,6 +72,7 @@ private OAuthUserInfo getGoogleUserInfo(String accessToken) { GoogleUserResponse response = restClient.get() .uri("https://www.googleapis.com/oauth2/v2/userinfo") .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .accept(MediaType.APPLICATION_JSON) .retrieve() .body(GoogleUserResponse.class); diff --git a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java index b200a6eb..ff45e4db 100644 --- a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java @@ -6,6 +6,7 @@ import com.mr.domain.auth.entity.enums.SocialType; import com.mr.domain.auth.exception.AuthErrorStatus; import com.mr.global.apipayload.exception.GeneralException; +import com.mr.global.config.OAuthRestClientConfig; import java.time.Duration; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; @@ -30,14 +31,15 @@ void setUp() { } @Test - @DisplayName("OAuthClientService 생성 시 connect/read timeout 설정이 정상 적용된다") - void createOAuthClientService_withTimeout() { + @DisplayName("OAuthRestClientConfig 생성 시 connect/read timeout 설정이 정상 적용된 RestClient가 생성된다") + void createOAuthRestClient_withTimeout() { Duration connectTimeout = Duration.ofSeconds(3); Duration readTimeout = Duration.ofSeconds(5); - OAuthClientService service = new OAuthClientService(connectTimeout, readTimeout); + OAuthRestClientConfig config = new OAuthRestClientConfig(); + RestClient client = config.oauthRestClient(connectTimeout, readTimeout); - assertThat(service).isNotNull(); + assertThat(client).isNotNull(); } @Test From 5336c2615c205510023faf00d47ea4731162e3cb Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:35:43 +0900 Subject: [PATCH 36/51] =?UTF-8?q?feat:=20=EC=84=A4=EC=A0=95=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/config/OAuthRestClientConfig.java | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 src/main/java/com/mr/global/config/OAuthRestClientConfig.java diff --git a/src/main/java/com/mr/global/config/OAuthRestClientConfig.java b/src/main/java/com/mr/global/config/OAuthRestClientConfig.java new file mode 100644 index 00000000..3befb305 --- /dev/null +++ b/src/main/java/com/mr/global/config/OAuthRestClientConfig.java @@ -0,0 +1,28 @@ +package com.mr.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.web.client.ClientHttpRequestFactories; +import org.springframework.boot.web.client.ClientHttpRequestFactorySettings; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +import java.time.Duration; + +@Configuration +public class OAuthRestClientConfig { + + @Bean(name = "oauthRestClient") + public RestClient oauthRestClient( + @Value("${oauth.connect-timeout:3s}") Duration connectTimeout, + @Value("${oauth.read-timeout:5s}") Duration readTimeout) { + + ClientHttpRequestFactorySettings settings = ClientHttpRequestFactorySettings.DEFAULTS + .withConnectTimeout(connectTimeout) + .withReadTimeout(readTimeout); + + return RestClient.builder() + .requestFactory(ClientHttpRequestFactories.get(settings)) + .build(); + } +} From 189cf55097e4951c426318ab71c30308ef55c69c Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:38:16 +0900 Subject: [PATCH 37/51] =?UTF-8?q?feat:=20getUserIdFromToken=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mr/global/security/jwt/JwtTokenProvider.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java index 1876acbb..4e3a8994 100644 --- a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java @@ -85,6 +85,11 @@ public Authentication getAuthentication(String token) { return new UsernamePasswordAuthenticationToken(userDetails, "", userDetails.getAuthorities()); } + public Long getUserIdFromToken(String token) { + Claims claims = parseClaims(token); + return Long.valueOf(claims.getSubject()); + } + public boolean validateAccessToken(String token) { return validateTokenWithType(token, ACCESS_TYPE); } From 1be422d2dd4edfecc88c1b1f48fe5789da44d6dc Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:38:49 +0900 Subject: [PATCH 38/51] =?UTF-8?q?feat:=20getUserIdFromToken=EC=82=AC?= =?UTF-8?q?=EC=9A=A9=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/auth/service/AuthService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index e14b04f4..5ce42907 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -144,7 +144,7 @@ public AuthResponseDTO.TokenInfo reissueToken(String refreshToken) { throw new GeneralException(AuthErrorStatus.INVALID_TOKEN); } - Long userId = Long.valueOf(tokenProvider.getAuthentication(refreshToken).getName()); + Long userId = tokenProvider.getUserIdFromToken(refreshToken); String requestTokenHash = tokenProvider.hashToken(refreshToken); SocialAuth socialAuth = socialAuthRepository.findByRefreshTokenHashWithLock(requestTokenHash) From f13ce0199e461d754aeadbb9d47fb9f985f0b68b Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:39:52 +0900 Subject: [PATCH 39/51] =?UTF-8?q?feat:=20uuid=20=EC=A0=81=EC=9A=A9=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java index 4e3a8994..bf12445c 100644 --- a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java @@ -23,6 +23,7 @@ import java.time.LocalDateTime; import java.time.ZoneId; import java.util.Date; +import java.util.UUID; @Component @RequiredArgsConstructor @@ -57,6 +58,7 @@ public String createAccessToken(Long userId) { return Jwts.builder() .setClaims(claims) + .setId(UUID.randomUUID().toString()) .setIssuedAt(now) .setExpiration(validity) .signWith(key, SignatureAlgorithm.HS256) @@ -71,6 +73,7 @@ public String createRefreshToken(Long userId) { return Jwts.builder() .setClaims(claims) + .setId(UUID.randomUUID().toString()) .setIssuedAt(now) .setExpiration(validity) .signWith(key, SignatureAlgorithm.HS256) From 92027d5d614b8d68de1a78c99da585e11bca4d6f Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:42:17 +0900 Subject: [PATCH 40/51] =?UTF-8?q?feat:=20reissueToken=20=EC=8B=9C=20expire?= =?UTF-8?q?dAt=20=EB=88=84=EB=9D=BD=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/auth/entity/SocialAuth.java | 4 ++++ src/main/java/com/mr/domain/auth/service/AuthService.java | 1 + 2 files changed, 5 insertions(+) diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index 5c044a29..aee5f405 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -124,4 +124,8 @@ public void expireToken() { this.refreshTokenHash = null; this.expiredAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); } + + public boolean isExpired() { + return this.expiredAt == null || !this.expiredAt.isAfter(LocalDateTime.now(ZoneId.of("Asia/Seoul"))); + } } \ No newline at end of file diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index 5ce42907..cb92c140 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -149,6 +149,7 @@ public AuthResponseDTO.TokenInfo reissueToken(String refreshToken) { SocialAuth socialAuth = socialAuthRepository.findByRefreshTokenHashWithLock(requestTokenHash) .filter(auth -> auth.getUser().getUserId().equals(userId)) + .filter(auth -> !auth.isExpired()) .orElseThrow(() -> new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST)); String newAccessToken = tokenProvider.createAccessToken(userId); From 363a09c24d9b31da6ca748d3b804da013d382a76 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:44:06 +0900 Subject: [PATCH 41/51] =?UTF-8?q?feat:=20JWT=20iat,=20exp=ED=83=80?= =?UTF-8?q?=EC=9E=84=EC=A1=B4=20=EC=A0=9C=EA=B1=B0=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/auth/entity/SocialAuth.java | 7 +++---- .../java/com/mr/global/security/jwt/JwtTokenProvider.java | 4 +--- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index aee5f405..ef4e981f 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -7,7 +7,6 @@ import com.mr.global.entity.BaseCreatedEntity; import jakarta.persistence.*; import java.time.LocalDateTime; -import java.time.ZoneId; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -106,7 +105,7 @@ private static void validateTokenValue(String token) { } private static void validateExpiryTime(LocalDateTime expiredAt) { - if (expiredAt == null || !expiredAt.isAfter(LocalDateTime.now(ZoneId.of("Asia/Seoul")))) { + if (expiredAt == null || !expiredAt.isAfter(LocalDateTime.now())) { throw new GeneralException(AuthErrorStatus.INVALID_TOKEN_EXPIRY); } } @@ -122,10 +121,10 @@ public void updateRefreshToken(String tokenHash, LocalDateTime newExpiredAt, Str public void expireToken() { this.refreshTokenHash = null; - this.expiredAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + this.expiredAt = LocalDateTime.now(); } public boolean isExpired() { - return this.expiredAt == null || !this.expiredAt.isAfter(LocalDateTime.now(ZoneId.of("Asia/Seoul"))); + return this.expiredAt == null || !this.expiredAt.isAfter(LocalDateTime.now()); } } \ No newline at end of file diff --git a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java index bf12445c..0e72b956 100644 --- a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java @@ -21,7 +21,6 @@ import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; -import java.time.ZoneId; import java.util.Date; import java.util.UUID; @@ -33,7 +32,6 @@ public class JwtTokenProvider { private static final String TOKEN_TYPE_CLAIM = "type"; private static final String ACCESS_TYPE = "access"; private static final String REFRESH_TYPE = "refresh"; - private static final String SEOUL_ZONE = "Asia/Seoul"; private final CustomUserDetailsService userDetailsService; private final JwtProperties jwtProperties; @@ -138,7 +136,7 @@ public String hashToken(String token) { } public LocalDateTime getRefreshTokenExpiryTime() { - return LocalDateTime.now(ZoneId.of(SEOUL_ZONE)) + return LocalDateTime.now() .plusSeconds(jwtProperties.refreshTokenValidityInSeconds()); } From 894c00cf1185b8ea96a3c417ea37d02ee4a9031e Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:45:35 +0900 Subject: [PATCH 42/51] =?UTF-8?q?feat:=20=EB=A1=9C=EA=B7=B8=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8B=9C=20=EC=97=B0=EA=B2=B0=20=EA=B8=B0=EA=B8=B0?= =?UTF-8?q?=20=EC=B4=88=EA=B8=B0=ED=99=94=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/com/mr/domain/auth/entity/SocialAuth.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index ef4e981f..2fdc856b 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -121,6 +121,7 @@ public void updateRefreshToken(String tokenHash, LocalDateTime newExpiredAt, Str public void expireToken() { this.refreshTokenHash = null; + this.deviceInfo = null; this.expiredAt = LocalDateTime.now(); } From d793aa9087e65950a31781954317e8a63724aa72 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:46:49 +0900 Subject: [PATCH 43/51] feat: RuntimeException-> IllegalStateException (#42) --- src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java index 0e72b956..cccb1621 100644 --- a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java @@ -131,7 +131,7 @@ public String hashToken(String token) { } return hexString.toString(); } catch (NoSuchAlgorithmException e) { - throw new RuntimeException("SHA-256 알고리즘을 찾을 수 없습니다.", e); + throw new IllegalStateException("SHA-256 알고리즘을 찾을 수 없습니다.", e); } } From cfa6b37418522907767a57b9f4146edbff2be77e Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:48:04 +0900 Subject: [PATCH 44/51] =?UTF-8?q?feat:=20=EC=9C=A0=EC=A0=80=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EB=94=94=20=EC=A7=81=EC=A0=91=20=ED=98=B8=EC=B6=9C=20?= =?UTF-8?q?(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/auth/controller/AuthController.java | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/controller/AuthController.java b/src/main/java/com/mr/domain/auth/controller/AuthController.java index 2e9cf8bc..6dd45b12 100644 --- a/src/main/java/com/mr/domain/auth/controller/AuthController.java +++ b/src/main/java/com/mr/domain/auth/controller/AuthController.java @@ -14,11 +14,11 @@ import org.springframework.http.HttpHeaders; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestAttribute; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.mr.global.security.SecurityUtil; @Tag(name = "Auth API", description = "인증 및 소셜 로그인 관련 API") @RestController @@ -58,9 +58,9 @@ public ApiResponse reissue( ) @PostMapping("/logout") public ApiResponse logout( - @Parameter(hidden = true) @RequestAttribute("userId") Long userId, @RequestBody @Valid AuthRequestDTO.LogoutRequest request ) { + Long userId = SecurityUtil.getCurrentUserId(); authService.logout(userId, request.refreshToken()); return ApiResponse.onSuccess(null); } @@ -70,9 +70,8 @@ public ApiResponse logout( description = "사용자 계정을 탈퇴 처리하고 저장된 소셜 인증 정보 및 Refresh Token 세션을 완전히 삭제합니다." ) @PostMapping("/withdraw") - public ApiResponse withdraw( - @Parameter(hidden = true) @RequestAttribute("userId") Long userId - ) { + public ApiResponse withdraw() { + Long userId = SecurityUtil.getCurrentUserId(); authService.withdraw(userId); return ApiResponse.onSuccess(null); } From 2af091b563f131c2a4afa80a3bb9e7db3c6e7617 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:50:04 +0900 Subject: [PATCH 45/51] =?UTF-8?q?feat:=20=EB=B6=88=ED=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=95=A4=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/mr/domain/auth/controller/AuthController.java | 1 + src/main/java/com/mr/global/security/SecurityConfig.java | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/controller/AuthController.java b/src/main/java/com/mr/domain/auth/controller/AuthController.java index 6dd45b12..d4dc0e7a 100644 --- a/src/main/java/com/mr/domain/auth/controller/AuthController.java +++ b/src/main/java/com/mr/domain/auth/controller/AuthController.java @@ -43,6 +43,7 @@ public ApiResponse socialLogin( AuthResponseDTO.LoginResponse response = authService.socialLogin(socialType, request.accessToken(), deviceInfo); return ApiResponse.onSuccess(response); } + @SecurityRequirements @Operation(summary = "토큰 재발급 API", description = "만료된 Access Token을 Refresh Token을 이용해 재발급합니다.") @PostMapping("/reissue") public ApiResponse reissue( diff --git a/src/main/java/com/mr/global/security/SecurityConfig.java b/src/main/java/com/mr/global/security/SecurityConfig.java index ce1e68bd..45fba52c 100644 --- a/src/main/java/com/mr/global/security/SecurityConfig.java +++ b/src/main/java/com/mr/global/security/SecurityConfig.java @@ -32,8 +32,7 @@ public class SecurityConfig { "/swagger-ui/**", "/v3/api-docs/**", "/api/auth/login/**", - "/api/auth/reissue", - "/api/auth/refactor" + "/api/auth/reissue" }; @Bean From 2a3f24ea93f60ec9854e409394f4053da4dfc2d7 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 01:52:04 +0900 Subject: [PATCH 46/51] =?UTF-8?q?feat:=20=EB=AC=B8=EC=9E=90=EC=98=81=20?= =?UTF-8?q?=EC=9E=98=EB=A6=BC=20=ED=97=AC=ED=8D=BC=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mr/domain/auth/controller/AuthController.java | 2 +- .../java/com/mr/domain/auth/entity/SocialAuth.java | 12 ++++++++++-- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/mr/domain/auth/controller/AuthController.java b/src/main/java/com/mr/domain/auth/controller/AuthController.java index d4dc0e7a..55ab9be1 100644 --- a/src/main/java/com/mr/domain/auth/controller/AuthController.java +++ b/src/main/java/com/mr/domain/auth/controller/AuthController.java @@ -38,7 +38,7 @@ public ApiResponse socialLogin( @Parameter(description = "소셜 로그인 제공자 (KAKAO, GOOGLE)", example = "KAKAO") @PathVariable(name = "socialType") SocialType socialType, @RequestBody @Valid AuthRequestDTO.SocialLoginRequest request, - @RequestHeader(value = HttpHeaders.USER_AGENT, required = false, defaultValue = "Unknown Device") String deviceInfo + @RequestHeader(value = HttpHeaders.USER_AGENT, defaultValue = "Unknown Device") String deviceInfo ) { AuthResponseDTO.LoginResponse response = authService.socialLogin(socialType, request.accessToken(), deviceInfo); return ApiResponse.onSuccess(response); diff --git a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java index 2fdc856b..32876389 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -61,7 +61,15 @@ private SocialAuth(User user, SocialType socialType, String socialId, this.socialId = socialId; this.refreshTokenHash = refreshTokenHash; this.expiredAt = expiredAt; - this.deviceInfo = deviceInfo; + this.deviceInfo = sanitizeDeviceInfo(deviceInfo); + } + + private static String sanitizeDeviceInfo(String deviceInfo) { + if (deviceInfo == null || deviceInfo.isBlank()) { + return "Unknown Device"; + } + String trimmed = deviceInfo.trim(); + return trimmed.length() > 255 ? trimmed.substring(0, 255) : trimmed; } public static SocialAuth create(User user, SocialType socialType, String socialId, @@ -116,7 +124,7 @@ public void updateRefreshToken(String tokenHash, LocalDateTime newExpiredAt, Str this.refreshTokenHash = tokenHash; this.expiredAt = newExpiredAt; - this.deviceInfo = deviceInfo; + this.deviceInfo = sanitizeDeviceInfo(deviceInfo); } public void expireToken() { From ad6c4f0d2b3339de44548a7af0770920fa2d1fca Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 02:01:20 +0900 Subject: [PATCH 47/51] =?UTF-8?q?feat:=20mapper=20=ED=8C=8C=EC=9D=BC=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/exception/OAuthExceptionMapper.java | 42 ++++++++++++++++++ .../mr/domain/auth/service/AuthService.java | 1 - .../auth/service/OAuthClientService.java | 43 ++++--------------- .../auth/service/OAuthClientServiceTest.java | 3 +- 4 files changed, 52 insertions(+), 37 deletions(-) create mode 100644 src/main/java/com/mr/domain/auth/exception/OAuthExceptionMapper.java diff --git a/src/main/java/com/mr/domain/auth/exception/OAuthExceptionMapper.java b/src/main/java/com/mr/domain/auth/exception/OAuthExceptionMapper.java new file mode 100644 index 00000000..b8162d96 --- /dev/null +++ b/src/main/java/com/mr/domain/auth/exception/OAuthExceptionMapper.java @@ -0,0 +1,42 @@ +package com.mr.domain.auth.exception; + +import com.mr.global.apipayload.exception.GeneralException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.HttpServerErrorException; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClientResponseException; + +@Slf4j +@Component +public class OAuthExceptionMapper { + + public GeneralException map(Exception e, String provider) { + if (e instanceof GeneralException ge) { + return ge; + } + if (e instanceof HttpClientErrorException clientErr) { + log.warn("{} OAuth 클라이언트 인증 오류 (status={}): {}", provider, clientErr.getStatusCode(), clientErr.getMessage()); + return new GeneralException(AuthErrorStatus.OAUTH_CLIENT_ERROR); + } + if (e instanceof HttpServerErrorException serverErr) { + log.error("{} OAuth 서버 오류 (status={}): {}", provider, serverErr.getStatusCode(), serverErr.getMessage()); + return new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR); + } + if (e instanceof ResourceAccessException netErr) { + log.error("{} OAuth 타임아웃/네트워크 통신 오류: {}", provider, netErr.getMessage()); + return new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR); + } + if (e instanceof RestClientResponseException rcre) { + log.error("{} OAuth HTTP 응답 예외 (status={}): {}", provider, rcre.getStatusCode(), rcre.getMessage()); + if (rcre.getStatusCode().is4xxClientError()) { + return new GeneralException(AuthErrorStatus.OAUTH_CLIENT_ERROR); + } else { + return new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR); + } + } + log.error("{} OAuth 사용자 정보 처리 중 예외 발생", provider, e); + return new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } +} diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index cb92c140..feabb084 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -42,7 +42,6 @@ public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String a try { return executeSocialLogin(socialType, userInfo, deviceInfo); } catch (DataIntegrityViolationException e) { - // 동시 가입 요청으로 DB Unique 제약조건(uk_social_auth_type_id) 위반 시, 기존 가입된 계정으로 로그인 재시도 return executeSocialLoginForExistingUser(socialType, userInfo, deviceInfo); } } diff --git a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java index 5097bf06..f26ae88e 100644 --- a/src/main/java/com/mr/domain/auth/service/OAuthClientService.java +++ b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java @@ -5,26 +5,27 @@ import com.mr.domain.auth.dto.res.KakaoUserResponse; import com.mr.domain.auth.entity.enums.SocialType; import com.mr.domain.auth.exception.AuthErrorStatus; +import com.mr.domain.auth.exception.OAuthExceptionMapper; import com.mr.global.apipayload.exception.GeneralException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Service; -import org.springframework.web.client.HttpClientErrorException; -import org.springframework.web.client.HttpServerErrorException; -import org.springframework.web.client.ResourceAccessException; import org.springframework.web.client.RestClient; -import org.springframework.web.client.RestClientResponseException; @Slf4j @Service public class OAuthClientService { private final RestClient restClient; + private final OAuthExceptionMapper exceptionMapper; - public OAuthClientService(@Qualifier("oauthRestClient") RestClient restClient) { + public OAuthClientService( + @Qualifier("oauthRestClient") RestClient restClient, + OAuthExceptionMapper exceptionMapper) { this.restClient = restClient; + this.exceptionMapper = exceptionMapper; } public OAuthUserInfo getUserInfo(SocialType socialType, String accessToken) { @@ -63,7 +64,7 @@ private OAuthUserInfo getKakaoUserInfo(String accessToken) { .build(); } catch (Exception e) { - throw mapOAuthException(e, "Kakao"); + throw exceptionMapper.map(e, "Kakao"); } } @@ -85,35 +86,7 @@ private OAuthUserInfo getGoogleUserInfo(String accessToken) { .profileImgUrl(response.picture()) .build(); } catch (Exception e) { - throw mapOAuthException(e, "Google"); + throw exceptionMapper.map(e, "Google"); } } - - private GeneralException mapOAuthException(Exception e, String provider) { - if (e instanceof GeneralException ge) { - return ge; - } - if (e instanceof HttpClientErrorException clientErr) { - log.warn("{} OAuth 클라이언트 인증 오류 (status={}): {}", provider, clientErr.getStatusCode(), clientErr.getMessage()); - return new GeneralException(AuthErrorStatus.OAUTH_CLIENT_ERROR); - } - if (e instanceof HttpServerErrorException serverErr) { - log.error("{} OAuth 서버 오류 (status={}): {}", provider, serverErr.getStatusCode(), serverErr.getMessage()); - return new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR); - } - if (e instanceof ResourceAccessException netErr) { - log.error("{} OAuth 타임아웃/네트워크 통신 오류: {}", provider, netErr.getMessage()); - return new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR); - } - if (e instanceof RestClientResponseException rcre) { - log.error("{} OAuth HTTP 응답 예외 (status={}): {}", provider, rcre.getStatusCode(), rcre.getMessage()); - if (rcre.getStatusCode().is4xxClientError()) { - return new GeneralException(AuthErrorStatus.OAUTH_CLIENT_ERROR); - } else { - return new GeneralException(AuthErrorStatus.OAUTH_SERVER_ERROR); - } - } - log.error("{} OAuth 사용자 정보 처리 중 예외 발생", provider, e); - return new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); - } } \ No newline at end of file diff --git a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java index ff45e4db..cefb16ed 100644 --- a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java @@ -5,6 +5,7 @@ import com.mr.domain.auth.entity.enums.SocialType; import com.mr.domain.auth.exception.AuthErrorStatus; +import com.mr.domain.auth.exception.OAuthExceptionMapper; import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.config.OAuthRestClientConfig; import java.time.Duration; @@ -27,7 +28,7 @@ class OAuthClientServiceTest { void setUp() { restClientBuilder = RestClient.builder(); mockServer = MockRestServiceServer.bindTo(restClientBuilder).build(); - oAuthClientService = new OAuthClientService(restClientBuilder.build()); + oAuthClientService = new OAuthClientService(restClientBuilder.build(), new OAuthExceptionMapper()); } @Test From 18d69ddbb5ef5ebb93d15ed12d1f06520f6c6a32 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 02:10:00 +0900 Subject: [PATCH 48/51] =?UTF-8?q?feat:=20=EC=86=8C=EC=85=9C=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=97=B0=EB=8F=99=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/controller/AuthController.java | 16 +++++++ .../mr/domain/auth/service/AuthService.java | 43 +++++++++++++++++++ .../domain/auth/service/AuthServiceTest.java | 20 +++++++++ 3 files changed, 79 insertions(+) diff --git a/src/main/java/com/mr/domain/auth/controller/AuthController.java b/src/main/java/com/mr/domain/auth/controller/AuthController.java index 55ab9be1..a262c044 100644 --- a/src/main/java/com/mr/domain/auth/controller/AuthController.java +++ b/src/main/java/com/mr/domain/auth/controller/AuthController.java @@ -53,6 +53,22 @@ public ApiResponse reissue( return ApiResponse.onSuccess(tokenInfo); } + @Operation( + summary = "소셜 계정 추가 연동 API", + description = "현재 로그인된 사용자의 계정에 새로운 소셜 계정(카카오/구글)을 추가로 연동합니다." + ) + @PostMapping("/link/{socialType}") + public ApiResponse linkSocialAccount( + @Parameter(description = "연동할 소셜 제공자 (KAKAO, GOOGLE)", example = "GOOGLE") + @PathVariable(name = "socialType") SocialType socialType, + @RequestBody @Valid AuthRequestDTO.SocialLoginRequest request, + @RequestHeader(value = HttpHeaders.USER_AGENT, defaultValue = "Unknown Device") String deviceInfo + ) { + Long userId = SecurityUtil.getCurrentUserId(); + AuthResponseDTO.TokenInfo tokenInfo = authService.linkSocialAccount(userId, socialType, request.accessToken(), deviceInfo); + return ApiResponse.onSuccess(tokenInfo); + } + @Operation( summary = "로그아웃 API", description = "현재 요청 기기의 Refresh Token 세션을 선택적으로 만료 처리합니다." diff --git a/src/main/java/com/mr/domain/auth/service/AuthService.java b/src/main/java/com/mr/domain/auth/service/AuthService.java index feabb084..7a968bed 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -127,6 +127,49 @@ private AuthResponseDTO.LoginResponse executeSocialLoginForExistingUser(SocialTy }); } + public AuthResponseDTO.TokenInfo linkSocialAccount(Long userId, SocialType socialType, String accessToken, String deviceInfo) { + OAuthUserInfo userInfo = oAuthClientService.getUserInfo(socialType, accessToken); + + return transactionTemplate.execute(status -> { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); + + Optional existingSocialAuth = socialAuthRepository.findBySocialTypeAndSocialId(socialType, userInfo.socialId()); + if (existingSocialAuth.isPresent()) { + SocialAuth socialAuth = existingSocialAuth.get(); + if (!socialAuth.getUser().getUserId().equals(userId)) { + throw new GeneralException(AuthErrorStatus.ALREADY_LINKED_SOCIAL_ACCOUNT); + } + } + + String newAccessToken = tokenProvider.createAccessToken(userId); + String newRefreshToken = tokenProvider.createRefreshToken(userId); + String refreshTokenHash = tokenProvider.hashToken(newRefreshToken); + LocalDateTime expiryTime = tokenProvider.getRefreshTokenExpiryTime(); + + if (existingSocialAuth.isPresent()) { + SocialAuth socialAuth = existingSocialAuth.get(); + socialAuth.updateRefreshToken(refreshTokenHash, expiryTime, deviceInfo); + } else { + SocialAuth newSocialAuth = SocialAuth.create( + user, + socialType, + userInfo.socialId(), + refreshTokenHash, + expiryTime, + deviceInfo + ); + socialAuthRepository.save(newSocialAuth); + } + + return AuthResponseDTO.TokenInfo.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .accessTokenExpiresInSeconds(tokenProvider.getAccessTokenExpirationSeconds()) + .build(); + }); + } + private User registerNewUser(OAuthUserInfo userInfo) { String profileImgUrl = userInfo.profileImgUrl(); if (profileImgUrl == null || profileImgUrl.isBlank()) { diff --git a/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java b/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java index 1683768a..2d88e06b 100644 --- a/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java @@ -139,4 +139,24 @@ void withdraw_deletesSocialAuthAndUserFromDb() { assertThat(userRepository.findById(userId)).isEmpty(); assertThat(socialAuthRepository.findAllByUser_UserId(userId)).isEmpty(); } + + @Test + @DisplayName("linkSocialAccount - 기존 사용자 계정에 다른 소셜 계정(구글)을 추가 연동할 수 있다") + void linkSocialAccount_existingUser_addsSecondSocialAuth() { + // given + given(oAuthClientService.getUserInfo(SocialType.KAKAO, "kakao_token")).willReturn(kakaoUserInfo); + AuthResponseDTO.LoginResponse loginResponse = authService.socialLogin(SocialType.KAKAO, "kakao_token", "deviceInfo"); + Long userId = loginResponse.userId(); + + OAuthUserInfo googleUserInfo = new OAuthUserInfo("google_67890", "https://example.com/google.png"); + given(oAuthClientService.getUserInfo(SocialType.GOOGLE, "google_token")).willReturn(googleUserInfo); + + // when + AuthResponseDTO.TokenInfo tokenInfo = authService.linkSocialAccount(userId, SocialType.GOOGLE, "google_token", "deviceInfo"); + + // then + assertThat(tokenInfo.accessToken()).isNotBlank(); + List userSocialAuths = socialAuthRepository.findAllByUser_UserId(userId); + assertThat(userSocialAuths).hasSize(2); + } } From b274c10013ed04ab6aad26a2ab82ee0d406afa82 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 02:14:36 +0900 Subject: [PATCH 49/51] =?UTF-8?q?feat:=20=EB=AA=A9=EB=8D=B0=EC=9D=B4?= =?UTF-8?q?=ED=84=B0=20=ED=98=B8=EC=B6=9C=20=EA=B2=80=EC=A6=9D=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/mr/domain/auth/service/OAuthClientServiceTest.java | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java index cefb16ed..70d59069 100644 --- a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java @@ -9,6 +9,7 @@ import com.mr.global.apipayload.exception.GeneralException; import com.mr.global.config.OAuthRestClientConfig; import java.time.Duration; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -31,6 +32,11 @@ void setUp() { oAuthClientService = new OAuthClientService(restClientBuilder.build(), new OAuthExceptionMapper()); } + @AfterEach + void tearDown() { + mockServer.verify(); + } + @Test @DisplayName("OAuthRestClientConfig 생성 시 connect/read timeout 설정이 정상 적용된 RestClient가 생성된다") void createOAuthRestClient_withTimeout() { From 17e4dc8eb4e3d4ebebc3814bc44ee4fa598f3346 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 02:16:31 +0900 Subject: [PATCH 50/51] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=84=B8=EB=B6=84=ED=99=94=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/service/OAuthClientServiceTest.java | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java index 70d59069..7e009e0e 100644 --- a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java @@ -103,4 +103,47 @@ void getGoogleUserInfo_500Error_throwsOauthServerError() { .isInstanceOf(GeneralException.class) .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AuthErrorStatus.OAUTH_SERVER_ERROR)); } + + @Test + @DisplayName("카카오 정상 응답 시 OAuthUserInfo(socialId, profileImgUrl)로 성공적으로 매핑된다") + void getKakaoUserInfo_success() { + String jsonResponse = """ + { + "id": 123456789, + "kakao_account": { + "profile": { + "profile_image_url": "https://example.com/kakao_profile.png" + } + } + } + """; + + mockServer.expect(MockRestRequestMatchers.requestTo("https://kapi.kakao.com/v2/user/me")) + .andRespond(MockRestResponseCreators.withSuccess(jsonResponse, MediaType.APPLICATION_JSON)); + + com.mr.domain.auth.dto.OAuthUserInfo userInfo = oAuthClientService.getUserInfo(SocialType.KAKAO, "valid_token"); + + assertThat(userInfo.socialId()).isEqualTo("123456789"); + assertThat(userInfo.profileImgUrl()).isEqualTo("https://example.com/kakao_profile.png"); + } + + @Test + @DisplayName("구글 정상 응답 시 OAuthUserInfo(socialId, profileImgUrl)로 성공적으로 매핑된다") + void getGoogleUserInfo_success() { + String jsonResponse = """ + { + "id": "google_987654321", + "picture": "https://example.com/google_profile.png", + "email": "user@gmail.com" + } + """; + + mockServer.expect(MockRestRequestMatchers.requestTo("https://www.googleapis.com/oauth2/v2/userinfo")) + .andRespond(MockRestResponseCreators.withSuccess(jsonResponse, MediaType.APPLICATION_JSON)); + + com.mr.domain.auth.dto.OAuthUserInfo userInfo = oAuthClientService.getUserInfo(SocialType.GOOGLE, "valid_token"); + + assertThat(userInfo.socialId()).isEqualTo("google_987654321"); + assertThat(userInfo.profileImgUrl()).isEqualTo("https://example.com/google_profile.png"); + } } From fc257efe97041424bd84088db5de510f83eb2f36 Mon Sep 17 00:00:00 2001 From: kimyw1018 Date: Wed, 29 Jul 2026 02:18:03 +0900 Subject: [PATCH 51/51] =?UTF-8?q?feat:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=8A=A4=20=ED=86=A0=ED=81=B0=20=EC=A0=84?= =?UTF-8?q?=EC=86=A1=20=EA=B2=80=EC=A6=9D=20(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/OAuthClientServiceTest.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java index 7e009e0e..6fa4f75b 100644 --- a/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java +++ b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java @@ -3,6 +3,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import com.mr.domain.auth.dto.OAuthUserInfo; import com.mr.domain.auth.entity.enums.SocialType; import com.mr.domain.auth.exception.AuthErrorStatus; import com.mr.domain.auth.exception.OAuthExceptionMapper; @@ -13,6 +14,7 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.test.web.client.MockRestServiceServer; import org.springframework.test.web.client.match.MockRestRequestMatchers; @@ -53,6 +55,7 @@ void createOAuthRestClient_withTimeout() { @DisplayName("카카오 response의 id가 null이면 GeneralException(INVALID_AUTH_REQUEST)이 발생한다") void getKakaoUserInfo_nullId_throwsException() { mockServer.expect(MockRestRequestMatchers.requestTo("https://kapi.kakao.com/v2/user/me")) + .andExpect(MockRestRequestMatchers.header(HttpHeaders.AUTHORIZATION, "Bearer token")) .andRespond(MockRestResponseCreators.withSuccess("{\"id\": null}", MediaType.APPLICATION_JSON)); assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.KAKAO, "token")) @@ -64,6 +67,7 @@ void getKakaoUserInfo_nullId_throwsException() { @DisplayName("카카오 response의 id가 'null' 문자열이면 GeneralException이 발생한다") void getKakaoUserInfo_literalNullId_throwsException() { mockServer.expect(MockRestRequestMatchers.requestTo("https://kapi.kakao.com/v2/user/me")) + .andExpect(MockRestRequestMatchers.header(HttpHeaders.AUTHORIZATION, "Bearer token")) .andRespond(MockRestResponseCreators.withSuccess("{\"id\": \"null\"}", MediaType.APPLICATION_JSON)); assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.KAKAO, "token")) @@ -75,6 +79,7 @@ void getKakaoUserInfo_literalNullId_throwsException() { @DisplayName("구글 response의 id가 없으면 GeneralException이 발생한다") void getGoogleUserInfo_missingId_throwsException() { mockServer.expect(MockRestRequestMatchers.requestTo("https://www.googleapis.com/oauth2/v2/userinfo")) + .andExpect(MockRestRequestMatchers.header(HttpHeaders.AUTHORIZATION, "Bearer token")) .andRespond(MockRestResponseCreators.withSuccess("{\"email\": \"test@example.com\"}", MediaType.APPLICATION_JSON)); assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.GOOGLE, "token")) @@ -86,6 +91,7 @@ void getGoogleUserInfo_missingId_throwsException() { @DisplayName("카카오 OAuth 서버가 401 Unauthorized를 반환하면 OAUTH_CLIENT_ERROR 예외로 매핑된다") void getKakaoUserInfo_401Error_throwsOauthClientError() { mockServer.expect(MockRestRequestMatchers.requestTo("https://kapi.kakao.com/v2/user/me")) + .andExpect(MockRestRequestMatchers.header(HttpHeaders.AUTHORIZATION, "Bearer invalid_token")) .andRespond(MockRestResponseCreators.withUnauthorizedRequest()); assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.KAKAO, "invalid_token")) @@ -97,6 +103,7 @@ void getKakaoUserInfo_401Error_throwsOauthClientError() { @DisplayName("구글 OAuth 서버가 500 Internal Server Error를 반환하면 OAUTH_SERVER_ERROR 예외로 매핑된다") void getGoogleUserInfo_500Error_throwsOauthServerError() { mockServer.expect(MockRestRequestMatchers.requestTo("https://www.googleapis.com/oauth2/v2/userinfo")) + .andExpect(MockRestRequestMatchers.header(HttpHeaders.AUTHORIZATION, "Bearer token")) .andRespond(MockRestResponseCreators.withServerError()); assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.GOOGLE, "token")) @@ -119,9 +126,10 @@ void getKakaoUserInfo_success() { """; mockServer.expect(MockRestRequestMatchers.requestTo("https://kapi.kakao.com/v2/user/me")) + .andExpect(MockRestRequestMatchers.header(HttpHeaders.AUTHORIZATION, "Bearer valid_token")) .andRespond(MockRestResponseCreators.withSuccess(jsonResponse, MediaType.APPLICATION_JSON)); - com.mr.domain.auth.dto.OAuthUserInfo userInfo = oAuthClientService.getUserInfo(SocialType.KAKAO, "valid_token"); + OAuthUserInfo userInfo = oAuthClientService.getUserInfo(SocialType.KAKAO, "valid_token"); assertThat(userInfo.socialId()).isEqualTo("123456789"); assertThat(userInfo.profileImgUrl()).isEqualTo("https://example.com/kakao_profile.png"); @@ -139,9 +147,10 @@ void getGoogleUserInfo_success() { """; mockServer.expect(MockRestRequestMatchers.requestTo("https://www.googleapis.com/oauth2/v2/userinfo")) + .andExpect(MockRestRequestMatchers.header(HttpHeaders.AUTHORIZATION, "Bearer valid_token")) .andRespond(MockRestResponseCreators.withSuccess(jsonResponse, MediaType.APPLICATION_JSON)); - com.mr.domain.auth.dto.OAuthUserInfo userInfo = oAuthClientService.getUserInfo(SocialType.GOOGLE, "valid_token"); + OAuthUserInfo userInfo = oAuthClientService.getUserInfo(SocialType.GOOGLE, "valid_token"); assertThat(userInfo.socialId()).isEqualTo("google_987654321"); assertThat(userInfo.profileImgUrl()).isEqualTo("https://example.com/google_profile.png");