diff --git a/src/main/java/com/onebyone/kindergarten/domain/facade/UserFacade.java b/src/main/java/com/onebyone/kindergarten/domain/facade/UserFacade.java index 9b658a0..ec3cf0b 100644 --- a/src/main/java/com/onebyone/kindergarten/domain/facade/UserFacade.java +++ b/src/main/java/com/onebyone/kindergarten/domain/facade/UserFacade.java @@ -8,6 +8,8 @@ import com.onebyone.kindergarten.domain.feignClient.NaverAuthClient; import com.onebyone.kindergarten.domain.provider.EmailProvider; import com.onebyone.kindergarten.domain.user.dto.UserDTO; +import com.onebyone.kindergarten.domain.user.dto.response.AppleUserResponse; +import com.onebyone.kindergarten.domain.user.service.AppleAuthService; import com.onebyone.kindergarten.domain.user.dto.response.*; import com.onebyone.kindergarten.domain.user.dto.request.SignInRequestDTO; import com.onebyone.kindergarten.domain.user.dto.request.SignUpRequestDTO; @@ -31,6 +33,7 @@ public class UserFacade { private final KakaoAuthClient kakaoAuthClient; private final NaverAuthClient naverAuthClient; private final NaverApiClient naverApiClient; + private final AppleAuthService appleAuthService; private final EmailProvider emailProvider; @Value("${oauth.kakao.secret-key}") private String kakaoApiKey; @@ -109,6 +112,20 @@ public SignInResponseDTO naverLogin(String code, String state) { .build(); } + public SignInResponseDTO appleLogin(String idToken) { + // Apple ID Token 검증 및 사용자 정보 추출 + AppleUserResponse userResponse = appleAuthService.verifyIdToken(idToken); + String email = userService.signUpByApple(userResponse); + + String accessToken = jwtProvider.generateAccessToken(email); + String refreshToken = jwtProvider.generateRefreshToken(email); + + return SignInResponseDTO.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + public PageCommunityCommentsResponseDTO getWroteMyCommunityComments(String username, int page, int size) { UserDTO user = userService.getUser(username); return communityCommentService.getWroteMyCommunityComments(user.getUserId(), page, size); diff --git a/src/main/java/com/onebyone/kindergarten/domain/feignClient/AppleAuthClient.java b/src/main/java/com/onebyone/kindergarten/domain/feignClient/AppleAuthClient.java new file mode 100644 index 0000000..3e1acb0 --- /dev/null +++ b/src/main/java/com/onebyone/kindergarten/domain/feignClient/AppleAuthClient.java @@ -0,0 +1,11 @@ +package com.onebyone.kindergarten.domain.feignClient; + +import com.onebyone.kindergarten.domain.user.dto.response.ApplePublicKeyResponse; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; + +@FeignClient(name = "appleAuthClient", url = "https://appleid.apple.com") +public interface AppleAuthClient { + @GetMapping("/auth/keys") + ApplePublicKeyResponse getPublicKeys(); +} \ No newline at end of file diff --git a/src/main/java/com/onebyone/kindergarten/domain/user/controller/UserApiController.java b/src/main/java/com/onebyone/kindergarten/domain/user/controller/UserApiController.java index 79260f7..925d0b6 100644 --- a/src/main/java/com/onebyone/kindergarten/domain/user/controller/UserApiController.java +++ b/src/main/java/com/onebyone/kindergarten/domain/user/controller/UserApiController.java @@ -106,7 +106,7 @@ public SignInResponseDTO getKakaoAuthorizationCode( return userFacade.kakaoLogin(code); } - @Operation(summary = "유저-09 네이버 소셜 로그인", description = "카카오 소셜로그인을 진행합니다") + @Operation(summary = "유저-09 네이버 소셜 로그인", description = "네이버 소셜로그인을 진행합니다") @GetMapping("/naver/callback") public SignInResponseDTO getNaverAuthorizationCode( @RequestParam(name = "code") String code, @@ -114,6 +114,13 @@ public SignInResponseDTO getNaverAuthorizationCode( return userFacade.naverLogin(code, state); } + @Operation(summary = "유저-10 애플 소셜 로그인", description = "애플 소셜로그인을 진행합니다") + @PostMapping("/apple/callback") + public SignInResponseDTO appleLogin( + @RequestParam(name = "id_token") String idToken) { + return userFacade.appleLogin(idToken); + } + @Operation(summary = "유저-010 작성한 리뷰 조회", description = "작성한 카테고리 리뷰를 조회합니다.") @GetMapping("/user/community-comments") public PageCommunityCommentsResponseDTO getWroteMyCommunityComments( diff --git a/src/main/java/com/onebyone/kindergarten/domain/user/dto/response/ApplePublicKeyResponse.java b/src/main/java/com/onebyone/kindergarten/domain/user/dto/response/ApplePublicKeyResponse.java new file mode 100644 index 0000000..4030936 --- /dev/null +++ b/src/main/java/com/onebyone/kindergarten/domain/user/dto/response/ApplePublicKeyResponse.java @@ -0,0 +1,19 @@ +package com.onebyone.kindergarten.domain.user.dto.response; + +import lombok.Data; +import java.util.List; + +@Data +public class ApplePublicKeyResponse { + private List keys; + + @Data + public static class Key { + private String kty; + private String kid; + private String use; + private String alg; + private String n; + private String e; + } +} \ No newline at end of file diff --git a/src/main/java/com/onebyone/kindergarten/domain/user/dto/response/AppleTokenResponse.java b/src/main/java/com/onebyone/kindergarten/domain/user/dto/response/AppleTokenResponse.java new file mode 100644 index 0000000..4ccfeec --- /dev/null +++ b/src/main/java/com/onebyone/kindergarten/domain/user/dto/response/AppleTokenResponse.java @@ -0,0 +1,12 @@ +package com.onebyone.kindergarten.domain.user.dto.response; + +import lombok.Data; + +@Data +public class AppleTokenResponse { + private String access_token; + private String token_type; + private String expires_in; + private String refresh_token; + private String id_token; // 애플의 JWT 토큰 +} \ No newline at end of file diff --git a/src/main/java/com/onebyone/kindergarten/domain/user/dto/response/AppleUserResponse.java b/src/main/java/com/onebyone/kindergarten/domain/user/dto/response/AppleUserResponse.java new file mode 100644 index 0000000..e23359c --- /dev/null +++ b/src/main/java/com/onebyone/kindergarten/domain/user/dto/response/AppleUserResponse.java @@ -0,0 +1,12 @@ +package com.onebyone.kindergarten.domain.user.dto.response; + +import lombok.Data; + +@Data +public class AppleUserResponse { + private String sub; // 애플 고유 사용자 ID + private String email; + private String name; + private Boolean email_verified; + private Boolean is_private_email; // 이메일 숨기기 여부 +} \ No newline at end of file diff --git a/src/main/java/com/onebyone/kindergarten/domain/user/entity/User.java b/src/main/java/com/onebyone/kindergarten/domain/user/entity/User.java index 5b7cda5..3565367 100644 --- a/src/main/java/com/onebyone/kindergarten/domain/user/entity/User.java +++ b/src/main/java/com/onebyone/kindergarten/domain/user/entity/User.java @@ -35,7 +35,10 @@ public class User extends BaseEntity { private Long kakaoProviderId; // 카카오 로그인 회사 당 할당받는 유저 pk @Column(name = "naver_id") - private String naverProviderId; // 카카오 로그인 회사 당 할당받는 유저 pk + private String naverProviderId; // 네이버 로그인 회사 당 할당받는 유저 pk + + @Column(name = "apple_id") + private String appleProviderId; // 애플 로그인 고유 사용자 ID @Column(nullable = false) private String nickname; // 닉네임 - 랜덤 생성 @@ -87,6 +90,19 @@ public static User registerNaver(String email, String password, String naverProv .build(); } + public static User registerApple(String email, String password, String appleProviderId, String nickname, + UserRole role) { + return User.builder() + .email(email) + .password(password) + .provider(UserProvider.APPLE) + .appleProviderId(appleProviderId) + .nickname(nickname) + .role(role) + .status(UserStatus.ACTIVE) + .build(); + } + public void changeNickname(String nickname) { this.nickname = nickname; } diff --git a/src/main/java/com/onebyone/kindergarten/domain/user/service/AppleAuthService.java b/src/main/java/com/onebyone/kindergarten/domain/user/service/AppleAuthService.java new file mode 100644 index 0000000..5be74c0 --- /dev/null +++ b/src/main/java/com/onebyone/kindergarten/domain/user/service/AppleAuthService.java @@ -0,0 +1,138 @@ +package com.onebyone.kindergarten.domain.user.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.onebyone.kindergarten.domain.feignClient.AppleAuthClient; +import com.onebyone.kindergarten.domain.user.dto.response.ApplePublicKeyResponse; +import com.onebyone.kindergarten.domain.user.dto.response.AppleUserResponse; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.Date; + +@Service +@RequiredArgsConstructor +@Slf4j +public class AppleAuthService { + + private final AppleAuthClient appleAuthClient; + private final ObjectMapper objectMapper; + + @Value("${oauth.apple.team-id}") + private String teamId; + + @Value("${oauth.apple.client-id}") + private String clientId; + + @Value("${oauth.apple.key-id}") + private String keyId; + + @Value("${oauth.apple.private-key}") + private String privateKey; + + @Value("${oauth.apple.audience:https://appleid.apple.com}") + private String audience; + + public AppleUserResponse verifyIdToken(String idToken) { + try { + // 1. JWT 헤더에서 kid 추출 + String[] tokenParts = idToken.split("\\."); + String header = new String(Base64.getUrlDecoder().decode(tokenParts[0])); + String kid = objectMapper.readTree(header).get("kid").asText(); + + // 2. Apple 공개키 조회 + ApplePublicKeyResponse publicKeys = appleAuthClient.getPublicKeys(); + ApplePublicKeyResponse.Key appleKey = publicKeys.getKeys().stream() + .filter(key -> key.getKid().equals(kid)) + .findFirst() + .orElseThrow(() -> new RuntimeException("Apple 공개키를 찾을 수 없습니다.")); + + // 3. 공개키로 JWT 검증 + PublicKey publicKey = generatePublicKey(appleKey.getN(), appleKey.getE()); + Claims claims = Jwts.parserBuilder() + .setSigningKey(publicKey) + .build() + .parseClaimsJws(idToken) + .getBody(); + + // 4. 사용자 정보 추출 + AppleUserResponse userResponse = new AppleUserResponse(); + userResponse.setSub(claims.getSubject()); + userResponse.setEmail(claims.get("email", String.class)); + userResponse.setEmail_verified(claims.get("email_verified", Boolean.class)); + userResponse.setIs_private_email(claims.get("is_private_email", Boolean.class)); + + // name은 첫 로그인 시에만 제공되므로 null일 수 있음 + Object nameObj = claims.get("name"); + if (nameObj != null) { + userResponse.setName(nameObj.toString()); + } + + return userResponse; + + } catch (Exception e) { + log.error("Apple ID Token 검증 실패: {}", e.getMessage()); + throw new RuntimeException("Apple 로그인 검증에 실패했습니다.", e); + } + } + + private PublicKey generatePublicKey(String nStr, String eStr) throws NoSuchAlgorithmException, InvalidKeySpecException { + byte[] nBytes = Base64.getUrlDecoder().decode(nStr); + byte[] eBytes = Base64.getUrlDecoder().decode(eStr); + + BigInteger n = new BigInteger(1, nBytes); + BigInteger e = new BigInteger(1, eBytes); + + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e); + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + return keyFactory.generatePublic(publicKeySpec); + } + + /** + * 애플로 토큰 요청 시 사용할 Client Secret JWT 생성 + */ + public String generateClientSecret() { + try { + long now = System.currentTimeMillis() / 1000; + + return Jwts.builder() + .setHeaderParam("kid", keyId) + .setHeaderParam("alg", "ES256") + .setIssuer(teamId) + .setIssuedAt(new Date(now * 1000)) + .setExpiration(new Date((now + 3600) * 1000)) // 1시간 후 만료 + .setAudience(audience) + .setSubject(clientId) + .signWith(getPrivateKey(), SignatureAlgorithm.ES256) + .compact(); + } catch (Exception e) { + log.error("Apple Client Secret 생성 실패: {}", e.getMessage()); + throw new RuntimeException("Apple Client Secret 생성에 실패했습니다.", e); + } + } + + private PrivateKey getPrivateKey() throws Exception { + String privateKeyContent = privateKey + .replace("-----BEGIN PRIVATE KEY-----", "") + .replace("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + + byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent); + PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory keyFactory = KeyFactory.getInstance("EC"); + return keyFactory.generatePrivate(keySpec); + } +} \ No newline at end of file diff --git a/src/main/java/com/onebyone/kindergarten/domain/user/service/UserService.java b/src/main/java/com/onebyone/kindergarten/domain/user/service/UserService.java index 9e2bb44..77bd0f9 100644 --- a/src/main/java/com/onebyone/kindergarten/domain/user/service/UserService.java +++ b/src/main/java/com/onebyone/kindergarten/domain/user/service/UserService.java @@ -7,6 +7,7 @@ import com.onebyone.kindergarten.domain.user.dto.request.SignInRequestDTO; import com.onebyone.kindergarten.domain.user.dto.request.SignUpRequestDTO; import com.onebyone.kindergarten.domain.user.dto.request.UpdateUserRoleRequestDTO; +import com.onebyone.kindergarten.domain.user.dto.response.AppleUserResponse; import com.onebyone.kindergarten.domain.user.dto.response.KakaoUserResponse; import com.onebyone.kindergarten.domain.user.dto.response.NaverUserResponse; import com.onebyone.kindergarten.domain.user.entity.EmailCertification; @@ -150,7 +151,7 @@ public String signUpByNaver(NaverUserResponse userResponse) { return email; } - String dummyPassword = encodePassword("kakao_" + userResponse.getResponse().getId()); + String dummyPassword = encodePassword("naver_" + userResponse.getResponse().getId()); User user = User.registerNaver(email, dummyPassword, userResponse.getResponse().getId(), userResponse.getResponse().getNickname(), UserRole.GENERAL, @@ -161,6 +162,38 @@ public String signUpByNaver(NaverUserResponse userResponse) { return user.getEmail(); } + @Transactional + public String signUpByApple(AppleUserResponse userResponse) { + String appleUserId = userResponse.getSub(); + String providedEmail = userResponse.getEmail(); + + // 이메일 숨기기 처리: 시스템 이메일 생성 + String systemEmail; + if (providedEmail != null && providedEmail.endsWith("@privaterelay.appleid.com")) { + // 익명 이메일인 경우 시스템 이메일 생성 + systemEmail = "apple_user_" + appleUserId.substring(0, Math.min(appleUserId.length(), 10)) + "@kindergarten.system"; + } else if (providedEmail != null) { + // 실제 이메일인 경우 그대로 사용 + systemEmail = providedEmail; + } else { + // 이메일이 없는 경우 시스템 이메일 생성 + systemEmail = "apple_user_" + appleUserId.substring(0, Math.min(appleUserId.length(), 10)) + "@kindergarten.system"; + } + + if (isExistedEmail(systemEmail)) { + return systemEmail; + } + + String dummyPassword = encodePassword("apple_" + appleUserId); + String nickname = userResponse.getName() != null ? userResponse.getName() : "애플_사용자_" + appleUserId.substring(0, 8); + + User user = User.registerApple(systemEmail, dummyPassword, appleUserId, nickname, UserRole.GENERAL); + + userRepository.save(user); + + return user.getEmail(); + } + @Transactional public void updateHomeShortcut(String email, HomeShortcutsDto homeShortcutsDto) { User user = findUser(email); diff --git a/src/main/java/com/onebyone/kindergarten/global/config/SecurityConfig.java b/src/main/java/com/onebyone/kindergarten/global/config/SecurityConfig.java index 7417747..f142b25 100644 --- a/src/main/java/com/onebyone/kindergarten/global/config/SecurityConfig.java +++ b/src/main/java/com/onebyone/kindergarten/global/config/SecurityConfig.java @@ -64,7 +64,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/", "/h2-console/**", "/users/sign-up", "/users/sign-in", "/swagger-ui/**", "/users/reissue", - "/users/kakao/callback", "/users/naver/callback", + "/users/kakao/callback", "/users/naver/callback", "/users/apple/callback", "/kindergarten/*/simple", "/users/email-certification", "/users/check-email-certification", "/v3/api-docs/**",