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..a262c044 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,95 @@ 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.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; +import com.mr.global.security.SecurityUtil; +@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, 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); } + @SecurityRequirements + @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("/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 세션을 선택적으로 만료 처리합니다." + ) + @PostMapping("/logout") + public ApiResponse logout( + @RequestBody @Valid AuthRequestDTO.LogoutRequest request + ) { + Long userId = SecurityUtil.getCurrentUserId(); + authService.logout(userId, request.refreshToken()); + return ApiResponse.onSuccess(null); + } + + @Operation( + summary = "회원 탈퇴 API", + description = "사용자 계정을 탈퇴 처리하고 저장된 소셜 인증 정보 및 Refresh Token 세션을 완전히 삭제합니다." + ) + @PostMapping("/withdraw") + public ApiResponse withdraw() { + Long userId = SecurityUtil.getCurrentUserId(); + authService.withdraw(userId); + return ApiResponse.onSuccess(null); + } } \ No newline at end of file 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/dto/AuthRequestDTO.java b/src/main/java/com/mr/domain/auth/dto/req/AuthRequestDTO.java similarity index 68% 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..888d3870 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; @@ -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/dto/AuthResponseDTO.java b/src/main/java/com/mr/domain/auth/dto/res/AuthResponseDTO.java similarity index 62% 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..e45967ed 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; @@ -11,11 +11,19 @@ public record TokenResponse( Long accessTokenExpiresInSeconds ) {} + @Builder + public record TokenInfo( + String accessToken, + String refreshToken, + Long accessTokenExpiresInSeconds + ) {} + @Builder 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/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 + ) {} +} 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..32876389 100644 --- a/src/main/java/com/mr/domain/auth/entity/SocialAuth.java +++ b/src/main/java/com/mr/domain/auth/entity/SocialAuth.java @@ -1,12 +1,12 @@ 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.*; import java.time.LocalDateTime; -import java.time.ZoneId; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -14,7 +14,6 @@ @Getter @Entity -// TODO: 추후 User 도메인 완성 시 인덱스 추가 @Table( name = "social_auth", uniqueConstraints = { @@ -29,9 +28,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; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; @Enumerated(EnumType.STRING) @Column(name = "social_type", nullable = false, length = 20) @@ -40,9 +39,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,72 +49,91 @@ 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 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; 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(Long userId, SocialType socialType, String socialId, - String encryptedToken, String tokenHash, LocalDateTime expiredAt, String deviceInfo) { + public static SocialAuth create(User user, SocialType socialType, String socialId, + String tokenHash, LocalDateTime expiredAt, String deviceInfo) { - validateTokenValue(encryptedToken); validateTokenValue(tokenHash); validateExpiryTime(expiredAt); return SocialAuth.builder() - .userId(userId) + .user(user) .socialType(socialType) .socialId(socialId) - .refreshToken(encryptedToken) .refreshTokenHash(tokenHash) .expiredAt(expiredAt) .deviceInfo(deviceInfo) .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); } } 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); } } - 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; + this.deviceInfo = sanitizeDeviceInfo(deviceInfo); } public void expireToken() { - this.refreshToken = null; this.refreshTokenHash = null; - this.expiredAt = LocalDateTime.now(ZoneId.of("Asia/Seoul")); // null 대신 현재 시각 기록 + this.deviceInfo = null; + this.expiredAt = LocalDateTime.now(); + } + + public boolean isExpired() { + return this.expiredAt == null || !this.expiredAt.isAfter(LocalDateTime.now()); } } \ No newline at end of file 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 } 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/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/repository/SocialAuthRepository.java b/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java new file mode 100644 index 00000000..dbf5ec26 --- /dev/null +++ b/src/main/java/com/mr/domain/auth/repository/SocialAuthRepository.java @@ -0,0 +1,23 @@ +package com.mr.domain.auth.repository; + +import com.mr.domain.auth.entity.SocialAuth; +import com.mr.domain.auth.entity.enums.SocialType; +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 { + + List findAllByUser_UserId(Long userId); + 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 4ccac698..7a968bed 100644 --- a/src/main/java/com/mr/domain/auth/service/AuthService.java +++ b/src/main/java/com/mr/domain/auth/service/AuthService.java @@ -1,47 +1,238 @@ 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.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.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; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class AuthService { - private final JwtTokenProvider jwtTokenProvider; - // private final KakaoOAuthService kakaoOAuthService; (외부 API 파싱용 서비스) - // private final GoogleOAuthService googleOAuthService; + private final SocialAuthRepository socialAuthRepository; + 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; + + 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) { + 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(); + + 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 + ); + socialAuthRepository.saveAndFlush(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 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(); + }); + } + + 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()) { + profileImgUrl = defaultProfileImageUrl; + } + + User user = User.createFromOAuth(profileImgUrl); + return userRepository.save(user); + } @Transactional - public AuthResponseDTO.LoginResponse socialLogin(SocialType socialType, String accessToken) { - // 1. 외부 소셜 API (카카오/구글) 통신하여 유저 프로필(email, socialId) 파싱 - // SocialUserInfo userInfo = getSocialUserInfo(socialType, accessToken); + public AuthResponseDTO.TokenInfo reissueToken(String refreshToken) { + if (!tokenProvider.validateRefreshToken(refreshToken)) { + throw new GeneralException(AuthErrorStatus.INVALID_TOKEN); + } - // 2. TODO: User 엔티티 연동 및 가입여부 검증 (Stub 구조) - // 만약 가입 안 되어있으면 DB User 생성 -> 저장 - Long mockUserId = 1L; - String mockEmail = "user@example.com"; - String mockNickname = "뮤즈유저"; - boolean isNewUser = false; + Long userId = tokenProvider.getUserIdFromToken(refreshToken); + String requestTokenHash = tokenProvider.hashToken(refreshToken); - String appAccessToken = jwtTokenProvider.createAccessToken(mockUserId); - String appRefreshToken = jwtTokenProvider.createRefreshToken(mockUserId); + 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); + String newRefreshToken = tokenProvider.createRefreshToken(userId); + String newRefreshTokenHash = tokenProvider.hashToken(newRefreshToken); - AuthResponseDTO.TokenResponse tokenResponse = AuthResponseDTO.TokenResponse.builder() - .accessToken(appAccessToken) - .refreshToken(appRefreshToken) - .accessTokenExpiresInSeconds(3600L) - .build(); - return AuthResponseDTO.LoginResponse.builder() - .userId(mockUserId) - .nickname(mockNickname) - .isNewUser(isNewUser) - .tokenInfo(tokenResponse) + socialAuth.updateRefreshToken(newRefreshTokenHash, tokenProvider.getRefreshTokenExpiryTime(), socialAuth.getDeviceInfo()); + + return AuthResponseDTO.TokenInfo.builder() + .accessToken(newAccessToken) + .refreshToken(newRefreshToken) + .accessTokenExpiresInSeconds(tokenProvider.getAccessTokenExpirationSeconds()) .build(); } + + @Transactional + public void logout(Long userId, String refreshToken) { + if (!tokenProvider.validateRefreshToken(refreshToken)) { + throw new GeneralException(AuthErrorStatus.INVALID_TOKEN); + } + + 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 + public void withdraw(Long userId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new GeneralException(UserErrorStatus.USER_NOT_FOUND)); + + List socialAuths = socialAuthRepository.findAllByUser_UserId(userId); + if (!socialAuths.isEmpty()) { + socialAuths.forEach(SocialAuth::expireToken); + socialAuthRepository.deleteAll(socialAuths); + } + + 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 new file mode 100644 index 00000000..f26ae88e --- /dev/null +++ b/src/main/java/com/mr/domain/auth/service/OAuthClientService.java @@ -0,0 +1,92 @@ +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.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.RestClient; + +@Slf4j +@Service +public class OAuthClientService { + + private final RestClient restClient; + private final OAuthExceptionMapper exceptionMapper; + + public OAuthClientService( + @Qualifier("oauthRestClient") RestClient restClient, + OAuthExceptionMapper exceptionMapper) { + this.restClient = restClient; + this.exceptionMapper = exceptionMapper; + } + + public OAuthUserInfo getUserInfo(SocialType socialType, String accessToken) { + return switch (socialType) { + case KAKAO -> getKakaoUserInfo(accessToken); + case GOOGLE -> getGoogleUserInfo(accessToken); + }; + } + + private OAuthUserInfo getKakaoUserInfo(String accessToken) { + try { + KakaoUserResponse response = restClient.get() + .uri("https://kapi.kakao.com/v2/user/me") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .body(KakaoUserResponse.class); + + if (response == null || response.id() == null) { + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } + + String socialId = String.valueOf(response.id()); + if (socialId.isBlank() || "null".equalsIgnoreCase(socialId.trim())) { + throw new GeneralException(AuthErrorStatus.INVALID_AUTH_REQUEST); + } + + String profileImgUrl = null; + if (response.kakaoAccount() != null && response.kakaoAccount().profile() != null) { + profileImgUrl = response.kakaoAccount().profile().profileImageUrl(); + } + + return OAuthUserInfo.builder() + .socialId(socialId) + .profileImgUrl(profileImgUrl) + .build(); + + } catch (Exception e) { + throw exceptionMapper.map(e, "Kakao"); + } + } + + private OAuthUserInfo getGoogleUserInfo(String accessToken) { + try { + 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); + + 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(response.id()) + .profileImgUrl(response.picture()) + .build(); + } catch (Exception e) { + throw exceptionMapper.map(e, "Google"); + } + } +} \ No newline at end of file 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/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(); + } +} diff --git a/src/main/java/com/mr/global/security/SecurityConfig.java b/src/main/java/com/mr/global/security/SecurityConfig.java index b28f5968..45fba52c 100644 --- a/src/main/java/com/mr/global/security/SecurityConfig.java +++ b/src/main/java/com/mr/global/security/SecurityConfig.java @@ -32,7 +32,7 @@ public class SecurityConfig { "/swagger-ui/**", "/v3/api-docs/**", "/api/auth/login/**", - "/api/auth/refactor" + "/api/auth/reissue" }; @Bean 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..cccb1621 100644 --- a/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/com/mr/global/security/jwt/JwtTokenProvider.java @@ -16,8 +16,13 @@ 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.util.Date; +import java.util.UUID; @Component @RequiredArgsConstructor @@ -51,6 +56,7 @@ public String createAccessToken(Long userId) { return Jwts.builder() .setClaims(claims) + .setId(UUID.randomUUID().toString()) .setIssuedAt(now) .setExpiration(validity) .signWith(key, SignatureAlgorithm.HS256) @@ -65,6 +71,7 @@ public String createRefreshToken(Long userId) { return Jwts.builder() .setClaims(claims) + .setId(UUID.randomUUID().toString()) .setIssuedAt(now) .setExpiration(validity) .signWith(key, SignatureAlgorithm.HS256) @@ -79,6 +86,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); } @@ -104,4 +116,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 IllegalStateException("SHA-256 알고리즘을 찾을 수 없습니다.", e); + } + } + + public LocalDateTime getRefreshTokenExpiryTime() { + return LocalDateTime.now() + .plusSeconds(jwtProperties.refreshTokenValidityInSeconds()); + } + + public Long getAccessTokenExpirationSeconds() { + return jwtProperties.accessTokenValidityInSeconds(); + } } \ No newline at end of file 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..2d88e06b --- /dev/null +++ b/src/test/java/com/mr/domain/auth/service/AuthServiceTest.java @@ -0,0 +1,162 @@ +package com.mr.domain.auth.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; + +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.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +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; + +@SpringBootTest +@Transactional +class AuthServiceTest { + + @Autowired + private AuthService authService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private SocialAuthRepository socialAuthRepository; + + @Autowired + private JwtTokenProvider tokenProvider; + + @MockBean + private OAuthClientService oAuthClientService; + + private OAuthUserInfo kakaoUserInfo; + + @BeforeEach + void setUp() { + kakaoUserInfo = new OAuthUserInfo("12345", "https://example.com/profile.png"); + } + + @Test + @DisplayName("socialLogin - 최초 소셜 로그인 시 DB에 User와 SocialAuth가 생성되고 isNewUser=true로 반환된다") + void socialLogin_newUser_savesToDbAndReturnsIsNewUserTrue() { + // given + 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(); + 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로 반환된다") + void socialLogin_existingUser_returnsIsNewUserFalse() { + // given + 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"); + + // then + assertThat(response.isNewUser()).isFalse(); + } + + @Test + @DisplayName("reissueToken - 발급된 실제 Refresh Token으로 토큰 재발급 요청 시 새로운 토큰 세트가 발급되고 DB 해시가 업데이트된다") + void reissueToken_realToken_reissuesTokensAndUpdateDbHash() throws InterruptedException { + // given + 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); + + Thread.sleep(1005); // JWT issuedAt(초 단위) 타임스탬프 차이 보장 + + // when + AuthResponseDTO.TokenInfo reissuedTokenInfo = authService.reissueToken(originalRefreshToken); + + // then + 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("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, refreshToken); + + // 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 + 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); + } +} 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..6fa4f75b --- /dev/null +++ b/src/test/java/com/mr/domain/auth/service/OAuthClientServiceTest.java @@ -0,0 +1,158 @@ +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.dto.OAuthUserInfo; +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; +import org.junit.jupiter.api.AfterEach; +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; +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(), new OAuthExceptionMapper()); + } + + @AfterEach + void tearDown() { + mockServer.verify(); + } + + @Test + @DisplayName("OAuthRestClientConfig 생성 시 connect/read timeout 설정이 정상 적용된 RestClient가 생성된다") + void createOAuthRestClient_withTimeout() { + Duration connectTimeout = Duration.ofSeconds(3); + Duration readTimeout = Duration.ofSeconds(5); + + OAuthRestClientConfig config = new OAuthRestClientConfig(); + RestClient client = config.oauthRestClient(connectTimeout, readTimeout); + + assertThat(client).isNotNull(); + } + + @Test + @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")) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AuthErrorStatus.INVALID_AUTH_REQUEST)); + } + + @Test + @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")) + .isInstanceOf(GeneralException.class) + .satisfies(e -> assertThat(((GeneralException) e).getCode()).isEqualTo(AuthErrorStatus.INVALID_AUTH_REQUEST)); + } + + @Test + @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")) + .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")) + .andExpect(MockRestRequestMatchers.header(HttpHeaders.AUTHORIZATION, "Bearer invalid_token")) + .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")) + .andExpect(MockRestRequestMatchers.header(HttpHeaders.AUTHORIZATION, "Bearer token")) + .andRespond(MockRestResponseCreators.withServerError()); + + assertThatThrownBy(() -> oAuthClientService.getUserInfo(SocialType.GOOGLE, "token")) + .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")) + .andExpect(MockRestRequestMatchers.header(HttpHeaders.AUTHORIZATION, "Bearer valid_token")) + .andRespond(MockRestResponseCreators.withSuccess(jsonResponse, MediaType.APPLICATION_JSON)); + + 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")) + .andExpect(MockRestRequestMatchers.header(HttpHeaders.AUTHORIZATION, "Bearer valid_token")) + .andRespond(MockRestResponseCreators.withSuccess(jsonResponse, MediaType.APPLICATION_JSON)); + + OAuthUserInfo userInfo = oAuthClientService.getUserInfo(SocialType.GOOGLE, "valid_token"); + + assertThat(userInfo.socialId()).isEqualTo("google_987654321"); + assertThat(userInfo.profileImgUrl()).isEqualTo("https://example.com/google_profile.png"); + } +} 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();