Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
}
Original file line number Diff line number Diff line change
Expand Up @@ -106,14 +106,21 @@ 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,
@RequestParam(name = "state") String state) {
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(
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Key> keys;

@Data
public static class Key {
private String kty;
private String kid;
private String use;
private String alg;
private String n;
private String e;
}
}
Original file line number Diff line number Diff line change
@@ -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 토큰
}
Original file line number Diff line number Diff line change
@@ -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; // 이메일 숨기기 여부
}
Original file line number Diff line number Diff line change
Expand Up @@ -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; // 닉네임 - 랜덤 생성
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand Down