diff --git a/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java b/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java index a680e0b..a4ce188 100644 --- a/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java +++ b/src/main/java/com/moongeul/backend/api/member/controller/MemberController.java @@ -77,6 +77,25 @@ public ResponseEntity> loginWithKakao(@Valid @Requ return ApiResponse.success(SuccessStatus.SEND_LOGIN_SUCCESS, response); } + @Operation( + summary = "애플 로그인 API", + description = "애플 인가코드을 통해 사용자의 정보를 등록 및 토큰 + 역할을 발급합니다." + + "
- type: 환경에 따라 local 또는 deploy를 보내주세요" + + "
- [enum]ROLE -> 처음사용자 : GUEST, 일반사용자 : USER, 관리자 : ADMIN" + ) + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "로그인 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "인가코드가 입력되지 않았습니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "유효하지 않은 인가코드 입니다."), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "500", description = "로그인 서버 오류 발생") + }) + @PostMapping("/apple/login") + public ResponseEntity> loginWithApple(@Valid @RequestBody LoginRequestDTO loginRequestDTO) { + + LoginResponseDTO response = memberService.loginWithApple(loginRequestDTO.getCode(), loginRequestDTO.getType()); + return ApiResponse.success(SuccessStatus.SEND_LOGIN_SUCCESS, response); + } + @Operation( summary = "사용자 정보 조회 API", description = "토큰을 통해 인증된 사용자의 정보를 반환합니다. userId 쿼리 파라미터가 없으면 본인 정보를 조회하고, 있으면 해당 사용자의 정보를 조회합니다." + diff --git a/src/main/java/com/moongeul/backend/api/member/dto/AccessTokenResponseDTO.java b/src/main/java/com/moongeul/backend/api/member/dto/AccessTokenResponseDTO.java index 3184503..9949d28 100644 --- a/src/main/java/com/moongeul/backend/api/member/dto/AccessTokenResponseDTO.java +++ b/src/main/java/com/moongeul/backend/api/member/dto/AccessTokenResponseDTO.java @@ -11,4 +11,10 @@ public class AccessTokenResponseDTO { // JSON의 access_token 필드를 이 변수에 매핑 @JsonProperty("access_token") private String accessToken; + + @JsonProperty("refresh_token") + private String refreshToken; + + @JsonProperty("id_token") + private String idToken; } diff --git a/src/main/java/com/moongeul/backend/api/member/dto/AppleInfoResponseDTO.java b/src/main/java/com/moongeul/backend/api/member/dto/AppleInfoResponseDTO.java new file mode 100644 index 0000000..67d8b03 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/dto/AppleInfoResponseDTO.java @@ -0,0 +1,17 @@ +package com.moongeul.backend.api.member.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class AppleInfoResponseDTO { + + private String id; + private String email; + private String name; +} diff --git a/src/main/java/com/moongeul/backend/api/member/dto/ApplePublicKeysResponseDTO.java b/src/main/java/com/moongeul/backend/api/member/dto/ApplePublicKeysResponseDTO.java new file mode 100644 index 0000000..608fec2 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/dto/ApplePublicKeysResponseDTO.java @@ -0,0 +1,25 @@ +package com.moongeul.backend.api.member.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Getter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class ApplePublicKeysResponseDTO { + + private List keys; + + @Getter + @NoArgsConstructor + @JsonIgnoreProperties(ignoreUnknown = true) + public static class ApplePublicKey { + private String kid; + private String alg; + private String n; + private String e; + } +} diff --git a/src/main/java/com/moongeul/backend/api/member/dto/AppleTokenHeaderDTO.java b/src/main/java/com/moongeul/backend/api/member/dto/AppleTokenHeaderDTO.java new file mode 100644 index 0000000..1bbec48 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/dto/AppleTokenHeaderDTO.java @@ -0,0 +1,14 @@ +package com.moongeul.backend.api.member.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@JsonIgnoreProperties(ignoreUnknown = true) +public class AppleTokenHeaderDTO { + + private String kid; + private String alg; +} diff --git a/src/main/java/com/moongeul/backend/api/member/entity/Member.java b/src/main/java/com/moongeul/backend/api/member/entity/Member.java index a89048d..0944df7 100644 --- a/src/main/java/com/moongeul/backend/api/member/entity/Member.java +++ b/src/main/java/com/moongeul/backend/api/member/entity/Member.java @@ -44,6 +44,9 @@ public class Member extends BaseTimeEntity { private String refreshToken; // Refresh Token + @Column(length = 2000) + private String socialRefreshToken; // OAuth Refresh Token + @Builder.Default @Column(nullable = false) private boolean isPushEnabled = true; // 푸시알림 허용, 기본값: ON @@ -70,6 +73,13 @@ public void updateRefreshToken(String refreshToken) { this.refreshToken = refreshToken; } + /** + * 소셜 리프레시 토큰 업데이트 + */ + public void updateSocialRefreshToken(String socialRefreshToken) { + this.socialRefreshToken = socialRefreshToken; + } + /** * 독서 취향 유형 업데이트 */ @@ -116,6 +126,7 @@ public void updatePushEnabled(boolean isPushEnabled) { public void withdrawMember() { this.socialId = null; // 재가입 가능하도록 null 처리 this.refreshToken = null; + this.socialRefreshToken = null; this.nickname = "(알 수 없음)"; this.profileImage = null; this.name = null; // 실명 정보 삭제 diff --git a/src/main/java/com/moongeul/backend/api/member/service/AppleOAuthService.java b/src/main/java/com/moongeul/backend/api/member/service/AppleOAuthService.java new file mode 100644 index 0000000..db58b84 --- /dev/null +++ b/src/main/java/com/moongeul/backend/api/member/service/AppleOAuthService.java @@ -0,0 +1,315 @@ +package com.moongeul.backend.api.member.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.moongeul.backend.api.member.dto.AccessTokenResponseDTO; +import com.moongeul.backend.api.member.dto.AppleInfoResponseDTO; +import com.moongeul.backend.api.member.dto.ApplePublicKeysResponseDTO; +import com.moongeul.backend.api.member.dto.AppleTokenHeaderDTO; +import com.moongeul.backend.common.config.webclient.WebClientErrorHandler; +import com.moongeul.backend.common.exception.BadRequestException; +import com.moongeul.backend.common.exception.InternalServerException; +import com.moongeul.backend.common.exception.UnauthorizedException; +import com.moongeul.backend.common.response.ErrorStatus; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; + +import java.math.BigInteger; +import java.io.IOException; +import java.net.URLDecoder; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.RSAPublicKeySpec; +import java.time.Instant; +import java.util.Base64; +import java.util.Collection; +import java.util.Date; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AppleOAuthService { + + private static final String APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token"; + private static final String APPLE_REVOKE_URL = "https://appleid.apple.com/auth/revoke"; + private static final String APPLE_PUBLIC_KEYS_URL = "https://appleid.apple.com/auth/keys"; + private static final String APPLE_ISSUER = "https://appleid.apple.com"; + private static final long APPLE_CLIENT_SECRET_EXPIRE_SECONDS = 60L * 60L * 24L * 30L; + + private final WebClient webClient; + private final ObjectMapper objectMapper; + + @Value("${oauth-config.apple.client-id:}") + private String clientId; + @Value("${oauth-config.apple.team-id:}") + private String teamId; + @Value("${oauth-config.apple.key-id:}") + private String keyId; + @Value("${oauth-config.apple.private-key:}") + private String privateKey; + + @Value("${oauth-config.apple.local:}") + private String localRedirectUri; + @Value("${oauth-config.apple.deploy:}") + private String deployRedirectUri; + + public AccessTokenResponseDTO getAppleToken(String code, String type) { + validateAppleLoginConfig(); + + String decodedCode = decodeAuthorizationCode(code); + String redirectUri = "deploy".equalsIgnoreCase(type) ? deployRedirectUri : localRedirectUri; + if (!StringUtils.hasText(redirectUri)) { + throw new InternalServerException(ErrorStatus.SERVER_ERROR.getMessage()); + } + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", clientId); + params.add("client_secret", createAppleClientSecret()); + params.add("code", decodedCode); + params.add("redirect_uri", redirectUri); + + log.info("Request Body: code={}, client_id={}, redirect_uri={}, grant_type={}", + code, clientId, redirectUri, "authorization_code"); + + AccessTokenResponseDTO tokenResponse = webClient.post() + .uri(APPLE_TOKEN_URL) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(params)) + .retrieve() + .onStatus(HttpStatusCode::isError, res -> WebClientErrorHandler.handleApiError(res, "getAppleToken")) + .bodyToMono(AccessTokenResponseDTO.class) + .block(); + + if (tokenResponse == null || !StringUtils.hasText(tokenResponse.getIdToken())) { + throw new UnauthorizedException(ErrorStatus.AUTH_UNAUTHORIZED.getMessage()); + } + + return tokenResponse; + } + + public AppleInfoResponseDTO getAppleUserInfo(String idToken) { + + if (!StringUtils.hasText(idToken)) { + throw new UnauthorizedException(ErrorStatus.AUTH_UNAUTHORIZED.getMessage()); + } + + AppleTokenHeaderDTO tokenHeader = parseIdTokenHeader(idToken); + ApplePublicKeysResponseDTO.ApplePublicKey applePublicKey = getApplePublicKey(tokenHeader); + Claims claims = parseAndValidateIdToken(idToken, applePublicKey); + + String socialId = claims.getSubject(); + if (!StringUtils.hasText(socialId)) { + throw new UnauthorizedException(ErrorStatus.AUTH_UNAUTHORIZED.getMessage()); + } + + String email = claims.get("email", String.class); + String name = claims.get("name", String.class); + + if (!StringUtils.hasText(name) && StringUtils.hasText(email)) { + int atIndex = email.indexOf('@'); + name = atIndex > 0 ? email.substring(0, atIndex) : email; + } + + return AppleInfoResponseDTO.builder() + .id(socialId) + .email(email) + .name(name) + .build(); + } + + // Apple 연동 해제 로직 + public void revokeAppleToken(String appleRefreshToken) { + validateAppleLoginConfig(); + + if (!StringUtils.hasText(appleRefreshToken)) { + throw new BadRequestException(ErrorStatus.INVALID_TOKEN_REQUEST.getMessage()); + } + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("client_id", clientId); + params.add("client_secret", createAppleClientSecret()); + params.add("token", appleRefreshToken); + params.add("token_type_hint", "refresh_token"); + + webClient.post() + .uri(APPLE_REVOKE_URL) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(params)) + .retrieve() + .onStatus(HttpStatusCode::isError, res -> WebClientErrorHandler.handleApiError(res, "revokeAppleToken")) + .bodyToMono(String.class) + .block(); + } + + private String createAppleClientSecret() { + PrivateKey applePrivateKey = getApplePrivateKey(); + Instant now = Instant.now(); + + try { + return Jwts.builder() + .header() + .keyId(keyId) + .and() + .issuer(teamId) + .subject(clientId) + .issuedAt(Date.from(now)) + .expiration(Date.from(now.plusSeconds(APPLE_CLIENT_SECRET_EXPIRE_SECONDS))) + .claim("aud", APPLE_ISSUER) + .signWith(applePrivateKey, Jwts.SIG.ES256) + .compact(); + } catch (JwtException | IllegalArgumentException e) { + log.error("Failed to generate Apple client secret", e); + throw new InternalServerException(ErrorStatus.SERVER_ERROR.getMessage()); + } + } + + private PrivateKey getApplePrivateKey() { + if (!StringUtils.hasText(privateKey)) { + throw new InternalServerException(ErrorStatus.SERVER_ERROR.getMessage()); + } + + String normalizedPrivateKey = privateKey + .replace("\\n", "\n") + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + try { + byte[] keyBytes = Base64.getDecoder().decode(normalizedPrivateKey); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + return KeyFactory.getInstance("EC").generatePrivate(keySpec); + } catch (IllegalArgumentException | GeneralSecurityException e) { + log.error("Failed to parse Apple private key", e); + throw new InternalServerException(ErrorStatus.SERVER_ERROR.getMessage()); + } + } + + private AppleTokenHeaderDTO parseIdTokenHeader(String idToken) { + String[] tokenParts = idToken.split("\\."); + if (tokenParts.length < 2) { + throw new UnauthorizedException(ErrorStatus.AUTH_UNAUTHORIZED.getMessage()); + } + + try { + byte[] decodedHeader = Base64.getUrlDecoder().decode(tokenParts[0]); + AppleTokenHeaderDTO tokenHeader = objectMapper.readValue(decodedHeader, AppleTokenHeaderDTO.class); + + if (!StringUtils.hasText(tokenHeader.getKid()) || !StringUtils.hasText(tokenHeader.getAlg())) { + throw new UnauthorizedException(ErrorStatus.AUTH_UNAUTHORIZED.getMessage()); + } + + return tokenHeader; + } catch (IOException | IllegalArgumentException e) { + log.error("Failed to parse Apple id_token header", e); + throw new UnauthorizedException(ErrorStatus.AUTH_UNAUTHORIZED.getMessage()); + } + } + + private ApplePublicKeysResponseDTO.ApplePublicKey getApplePublicKey(AppleTokenHeaderDTO tokenHeader) { + ApplePublicKeysResponseDTO applePublicKeys = webClient.get() + .uri(APPLE_PUBLIC_KEYS_URL) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus(HttpStatusCode::isError, res -> WebClientErrorHandler.handleApiError(res, "getApplePublicKey")) + .bodyToMono(ApplePublicKeysResponseDTO.class) + .block(); + + if (applePublicKeys == null || applePublicKeys.getKeys() == null || applePublicKeys.getKeys().isEmpty()) { + throw new InternalServerException(ErrorStatus.SERVER_ERROR.getMessage()); + } + + return applePublicKeys.getKeys().stream() + .filter(key -> tokenHeader.getKid().equals(key.getKid()) + && tokenHeader.getAlg().equalsIgnoreCase(key.getAlg())) + .findFirst() + .orElseThrow(() -> new UnauthorizedException(ErrorStatus.AUTH_UNAUTHORIZED.getMessage())); + } + + private Claims parseAndValidateIdToken(String idToken, ApplePublicKeysResponseDTO.ApplePublicKey applePublicKey) { + RSAPublicKey publicKey = convertToPublicKey(applePublicKey); + + try { + Claims claims = Jwts.parser() + .verifyWith(publicKey) + .build() + .parseSignedClaims(idToken) + .getPayload(); + + validateIssuer(claims); + validateAudience(claims); + return claims; + } catch (JwtException | IllegalArgumentException e) { + log.error("Failed to validate Apple id_token", e); + throw new UnauthorizedException(ErrorStatus.AUTH_UNAUTHORIZED.getMessage()); + } + } + + private RSAPublicKey convertToPublicKey(ApplePublicKeysResponseDTO.ApplePublicKey applePublicKey) { + try { + BigInteger modulus = new BigInteger(1, Base64.getUrlDecoder().decode(applePublicKey.getN())); + BigInteger exponent = new BigInteger(1, Base64.getUrlDecoder().decode(applePublicKey.getE())); + + RSAPublicKeySpec rsaPublicKeySpec = new RSAPublicKeySpec(modulus, exponent); + return (RSAPublicKey) KeyFactory.getInstance("RSA").generatePublic(rsaPublicKeySpec); + } catch (IllegalArgumentException | GeneralSecurityException e) { + log.error("Failed to convert Apple public key", e); + throw new InternalServerException(ErrorStatus.SERVER_ERROR.getMessage()); + } + } + + private void validateIssuer(Claims claims) { + if (!APPLE_ISSUER.equals(claims.getIssuer())) { + throw new UnauthorizedException(ErrorStatus.AUTH_UNAUTHORIZED.getMessage()); + } + } + + private void validateAudience(Claims claims) { + Object audience = claims.get("aud"); + boolean isValidAudience = false; + + if (audience instanceof String audienceValue) { + isValidAudience = clientId.equals(audienceValue); + } else if (audience instanceof Collection audienceValues) { + isValidAudience = audienceValues.stream() + .map(String::valueOf) + .anyMatch(clientId::equals); + } + + if (!isValidAudience) { + throw new UnauthorizedException(ErrorStatus.AUTH_UNAUTHORIZED.getMessage()); + } + } + + private String decodeAuthorizationCode(String code) { + try { + return URLDecoder.decode(code, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + log.error("Apple authorization code URL decoding failed. Use raw code.", e); + return code; + } + } + + private void validateAppleLoginConfig() { + if (!StringUtils.hasText(clientId) + || !StringUtils.hasText(teamId) + || !StringUtils.hasText(keyId)) { + throw new InternalServerException(ErrorStatus.SERVER_ERROR.getMessage()); + } + } +} diff --git a/src/main/java/com/moongeul/backend/api/member/service/GoogleOAuthService.java b/src/main/java/com/moongeul/backend/api/member/service/GoogleOAuthService.java index a38de72..028cc68 100644 --- a/src/main/java/com/moongeul/backend/api/member/service/GoogleOAuthService.java +++ b/src/main/java/com/moongeul/backend/api/member/service/GoogleOAuthService.java @@ -3,6 +3,8 @@ import com.moongeul.backend.api.member.dto.GoogleInfoResponseDTO; import com.moongeul.backend.api.member.dto.AccessTokenResponseDTO; import com.moongeul.backend.common.config.webclient.WebClientErrorHandler; +import com.moongeul.backend.common.exception.BadRequestException; +import com.moongeul.backend.common.response.ErrorStatus; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -12,6 +14,7 @@ import org.springframework.stereotype.Service; import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.MultiValueMap; +import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.BodyInserters; import org.springframework.web.reactive.function.client.WebClient; @@ -25,6 +28,7 @@ public class GoogleOAuthService { private static final String GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"; private static final String GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v3/userinfo"; + private static final String GOOGLE_REVOKE_URL = "https://oauth2.googleapis.com/revoke"; private final WebClient webClient; @@ -87,4 +91,23 @@ public GoogleInfoResponseDTO getGoogleUserInfo(String googleAccessToken) { .bodyToMono(GoogleInfoResponseDTO.class) .block(); // 동기 방식으로 결과 대기 } + + // Google 연동 해제 로직 + public void revokeGoogleToken(String googleRefreshToken) { + if (!StringUtils.hasText(googleRefreshToken)) { + throw new BadRequestException(ErrorStatus.INVALID_TOKEN_REQUEST.getMessage()); + } + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("token", googleRefreshToken); + + webClient.post() + .uri(GOOGLE_REVOKE_URL) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(BodyInserters.fromFormData(params)) + .retrieve() + .onStatus(HttpStatusCode::isError, res -> WebClientErrorHandler.handleApiError(res, "revokeGoogleToken")) + .bodyToMono(String.class) + .block(); + } } diff --git a/src/main/java/com/moongeul/backend/api/member/service/MemberService.java b/src/main/java/com/moongeul/backend/api/member/service/MemberService.java index 22adffc..c81139c 100644 --- a/src/main/java/com/moongeul/backend/api/member/service/MemberService.java +++ b/src/main/java/com/moongeul/backend/api/member/service/MemberService.java @@ -75,6 +75,7 @@ public class MemberService { private final JwtTokenProvider jwtTokenProvider; private final GoogleOAuthService googleOAuthService; private final KakaoOAuthService kakaoOAuthService; + private final AppleOAuthService appleOAuthService; private final NicknameGenerator nicknameGenerator; private final FileUploadService fileUploadService; @@ -98,6 +99,10 @@ public LoginResponseDTO loginWithGoogle(String code, String type){ .map(entity -> entity.update(name)) // 이미 있으면 정보 업데이트 .orElseGet(() -> signUp(socialId, email, name, picture, socialType)); // 없으면 신규 회원가입 + if (StringUtils.hasText(tokenDTO.getRefreshToken())) { + member.updateSocialRefreshToken(tokenDTO.getRefreshToken()); + } + // 4. 자체 JWT 토큰 생성 및 반환 JwtTokenDTO jwtToken = jwtTokenProvider.generateToken(member); member.updateRefreshToken(jwtToken.getRefreshToken()); // 생성된 refreshToken DB 저장 @@ -148,6 +153,44 @@ public LoginResponseDTO loginWithKakao(String code, String type){ .build(); } + @Transactional + public LoginResponseDTO loginWithApple(String code, String type){ + + AccessTokenResponseDTO tokenDTO = appleOAuthService.getAppleToken(code, type); + AppleInfoResponseDTO userInfo = appleOAuthService.getAppleUserInfo(tokenDTO.getIdToken()); + + String socialId = userInfo.getId(); + String email = StringUtils.hasText(userInfo.getEmail()) ? userInfo.getEmail() : UUID.randomUUID() + "@socialUser.com"; + String name = StringUtils.hasText(userInfo.getName()) ? userInfo.getName() : socialId; + String socialType = "apple"; + + Member member = memberRepository.findBySocialId(socialId) + .map(entity -> { + if (StringUtils.hasText(name)) { + entity.update(name); + } + return entity; + }) + .orElseGet(() -> signUp(socialId, email, name, null, socialType)); + + if (StringUtils.hasText(tokenDTO.getRefreshToken())) { + member.updateSocialRefreshToken(tokenDTO.getRefreshToken()); + } + + JwtTokenDTO jwtToken = jwtTokenProvider.generateToken(member); + member.updateRefreshToken(jwtToken.getRefreshToken()); + + boolean isReadingTaste = (member.getReadingTasteType() != null); + + return LoginResponseDTO.builder() + .memberId(member.getId()) + .role(member.getAuthorityKey()) + .accessToken(jwtToken.getAccessToken()) + .refreshToken(jwtToken.getRefreshToken()) + .isReadingTaste(isReadingTaste) + .build(); + } + // 신규 회원가입 처리 로직 (DB 저장) private Member signUp(String socialId, String email, String name, String picture, String socialType) { Member newUser = Member.builder() @@ -224,6 +267,8 @@ public void withdraw(String email, WithdrawalRequestDTO withdrawalRequestDTO) { String profileImageUrl = member.getProfileImage(); String targetDomain = "https://api-bucket.rhkr8521.com"; + revokeSocialConnection(member); + /* 1. 벌크 삭제 쿼리 실행 (각 Repository에 작성된 @Modifying 쿼리 호출) - 호출 순서 중요! */ // [팔로우] 내가 팔로우한 & 나를 팔로우한 사람들 삭제 followRepository.deleteAllByFollowerId(member.getId()); @@ -278,6 +323,34 @@ public void withdraw(String email, WithdrawalRequestDTO withdrawalRequestDTO) { memberRepository.saveAndFlush(member); } + private void revokeSocialConnection(Member member) { + if (!StringUtils.hasText(member.getSocialType())) { + return; + } + + String socialType = member.getSocialType().toLowerCase(Locale.ROOT); + String socialRefreshToken = member.getSocialRefreshToken(); + + switch (socialType) { + case "google" -> { + if (!StringUtils.hasText(socialRefreshToken)) { + log.warn("구글 연동 해제 스킵 - 소셜 리프레시 토큰 없음. memberId={}", member.getId()); + return; + } + googleOAuthService.revokeGoogleToken(socialRefreshToken); + } + case "apple" -> { + if (!StringUtils.hasText(socialRefreshToken)) { + log.warn("애플 연동 해제 스킵 - 소셜 리프레시 토큰 없음. memberId={}", member.getId()); + return; + } + appleOAuthService.revokeAppleToken(socialRefreshToken); + } + default -> { + } + } + } + /* 기록 통계 조회 (마이페이지 기록장) */ @Transactional(readOnly = true) public PostStatsResponseDTO getPostStats(String email, Long userId) { diff --git a/src/main/java/com/moongeul/backend/common/config/security/SecurityConfig.java b/src/main/java/com/moongeul/backend/common/config/security/SecurityConfig.java index b3ac382..e73c08f 100644 --- a/src/main/java/com/moongeul/backend/common/config/security/SecurityConfig.java +++ b/src/main/java/com/moongeul/backend/common/config/security/SecurityConfig.java @@ -35,7 +35,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/", "/h2-console/**").permitAll() .requestMatchers("/static/**", "/index.html", "/firebase-messaging-sw.js", "/favicon.ico").permitAll() .requestMatchers("/v3/api-docs/**", "/api-doc/**", "/swagger-ui/**").permitAll() - .requestMatchers("/api/v2/member/google/login", "/api/v2/member/kakao/login", "/api/v2/member/reissue-token").permitAll() + .requestMatchers("/api/v2/member/google/login", "/api/v2/member/kakao/login", "/api/v2/member/apple/login", "/api/v2/member/reissue-token").permitAll() .requestMatchers("/api/v2/reading-taste", "/api/v2/reading-taste/total-count").permitAll() .requestMatchers("/api/v2/book/bestseller").permitAll() .requestMatchers(HttpMethod.GET, "/api/v2/book/bestseller/**").permitAll()