diff --git a/src/main/java/com/groom/swipo/domain/auth/dto/ApplePublicKey.java b/src/main/java/com/groom/swipo/domain/auth/dto/ApplePublicKey.java new file mode 100644 index 0000000..a218744 --- /dev/null +++ b/src/main/java/com/groom/swipo/domain/auth/dto/ApplePublicKey.java @@ -0,0 +1,9 @@ +package com.groom.swipo.domain.auth.dto; + +public record ApplePublicKey( + String kty, + String kid, + String alg, + String n, + String e) { +} \ No newline at end of file diff --git a/src/main/java/com/groom/swipo/domain/auth/dto/request/AppleLoginRequest.java b/src/main/java/com/groom/swipo/domain/auth/dto/request/AppleLoginRequest.java new file mode 100644 index 0000000..441fcae --- /dev/null +++ b/src/main/java/com/groom/swipo/domain/auth/dto/request/AppleLoginRequest.java @@ -0,0 +1,6 @@ +package com.groom.swipo.domain.auth.dto.request; + +public record AppleLoginRequest ( + String token +){ +} diff --git a/src/main/java/com/groom/swipo/domain/auth/dto/response/ApplePublicKeyResponse.java b/src/main/java/com/groom/swipo/domain/auth/dto/response/ApplePublicKeyResponse.java new file mode 100644 index 0000000..40ff61a --- /dev/null +++ b/src/main/java/com/groom/swipo/domain/auth/dto/response/ApplePublicKeyResponse.java @@ -0,0 +1,15 @@ +package com.groom.swipo.domain.auth.dto.response; + +import java.util.List; +import javax.naming.AuthenticationException; + +import com.groom.swipo.domain.auth.dto.ApplePublicKey; + +public record ApplePublicKeyResponse(List keys) { + public ApplePublicKey getMatchedKey(String kid, String alg) throws AuthenticationException { + return keys.stream() + .filter(key -> key.kid().equals(kid) && key.alg().equals(alg)) + .findAny() + .orElseThrow(AuthenticationException::new); + } +} \ No newline at end of file diff --git a/src/main/java/com/groom/swipo/domain/auth/dto/response/KakaoLoginResponse.java b/src/main/java/com/groom/swipo/domain/auth/dto/response/SocialLoginResponse.java similarity index 55% rename from src/main/java/com/groom/swipo/domain/auth/dto/response/KakaoLoginResponse.java rename to src/main/java/com/groom/swipo/domain/auth/dto/response/SocialLoginResponse.java index e8151d0..65d918b 100644 --- a/src/main/java/com/groom/swipo/domain/auth/dto/response/KakaoLoginResponse.java +++ b/src/main/java/com/groom/swipo/domain/auth/dto/response/SocialLoginResponse.java @@ -3,23 +3,23 @@ import lombok.Builder; @Builder -public record KakaoLoginResponse( +public record SocialLoginResponse( Long userId, String accessToken, String refreshToken, String providerId, String profileImage ) { - public static KakaoLoginResponse of(Long userId, String accessToken, String refreshToken) { - return KakaoLoginResponse.builder() + public static SocialLoginResponse of(Long userId, String accessToken, String refreshToken) { + return SocialLoginResponse.builder() .userId(userId) .accessToken(accessToken) .refreshToken(refreshToken) .build(); } - public static KakaoLoginResponse of(String providerId, String profileImage) { - return KakaoLoginResponse.builder() + public static SocialLoginResponse of(String providerId, String profileImage) { + return SocialLoginResponse.builder() .providerId(providerId) .profileImage(profileImage) .build(); diff --git a/src/main/java/com/groom/swipo/domain/auth/exception/AppleAuthException.java b/src/main/java/com/groom/swipo/domain/auth/exception/AppleAuthException.java new file mode 100644 index 0000000..c22d2bb --- /dev/null +++ b/src/main/java/com/groom/swipo/domain/auth/exception/AppleAuthException.java @@ -0,0 +1,12 @@ +package com.groom.swipo.domain.auth.exception; + +import com.groom.swipo.global.error.exception.AuthGroupException; + +public class AppleAuthException extends AuthGroupException { + public AppleAuthException(String message) { + super(message); + } + public AppleAuthException() { + this("애플 서버와의 통신 과정에서 문제가 발생했습니다."); + } +} \ No newline at end of file diff --git a/src/main/java/com/groom/swipo/domain/auth/service/AppleLoginService.java b/src/main/java/com/groom/swipo/domain/auth/service/AppleLoginService.java new file mode 100644 index 0000000..f48214d --- /dev/null +++ b/src/main/java/com/groom/swipo/domain/auth/service/AppleLoginService.java @@ -0,0 +1,94 @@ +package com.groom.swipo.domain.auth.service; + +import java.net.URI; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.util.Map; +import javax.naming.AuthenticationException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.client.RestClient; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.groom.swipo.domain.auth.dto.response.ApplePublicKeyResponse; +import com.groom.swipo.domain.auth.dto.response.SocialLoginResponse; +import com.groom.swipo.domain.auth.exception.AppleAuthException; +import com.groom.swipo.domain.auth.exception.InvalidTokenException; +import com.groom.swipo.domain.auth.util.ApplePublicKeyGenerator; +import com.groom.swipo.domain.auth.util.JwtValidator; +import com.groom.swipo.domain.user.entity.User; +import com.groom.swipo.domain.user.entity.enums.Provider; +import com.groom.swipo.domain.user.repository.UserRepository; +import com.groom.swipo.global.jwt.TokenProvider; + +import io.jsonwebtoken.ExpiredJwtException; +import lombok.RequiredArgsConstructor; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AppleLoginService { + + private static final String APPLE_PUBLIC_KEY_URL = "https://appleid.apple.com/auth/keys"; + + private final ApplePublicKeyGenerator applePublicKeyGenerator; + private final JwtValidator jwtValidator; + private final TokenProvider tokenProvider; + private final TokenRenewService tokenRenewService; + private final RestClient restClient; + private final UserRepository userRepository; + private final ObjectMapper objectMapper; + + @Transactional + public SocialLoginResponse appleLogin(String code) { + try { + String appleAccountId = getAppleAccountId(code); + return userRepository.findByProviderAndProviderId(Provider.APPLE, appleAccountId) + .map(this::handleExistingUserLogin) + .orElseGet(() -> SocialLoginResponse.of(appleAccountId, "default 이미지입니다.")); + } catch (JsonProcessingException e) { + throw new InvalidTokenException("JSON 형식이 잘못되었습니다."); + } catch (AuthenticationException e) { + throw new AppleAuthException("인증 실패"); + } catch (NoSuchAlgorithmException | InvalidKeySpecException e) { + throw new InvalidTokenException("유효하지 않는 identity Token 입니다."); + } + catch (IllegalArgumentException e) { + throw new InvalidTokenException("토큰이 만료되었거나 잘못된 인자가 포함되어있습니다."); + } + } + + private SocialLoginResponse handleExistingUserLogin(User user) { + String accessToken = tokenProvider.createAccessToken(user); + String refreshToken = tokenProvider.createRefreshToken(user); + tokenRenewService.saveRefreshToken(refreshToken, user.getId()); + return SocialLoginResponse.of(user.getId(), accessToken, refreshToken); + } + + private String getAppleAccountId(String identityToken) + throws JsonProcessingException, AuthenticationException, NoSuchAlgorithmException, InvalidKeySpecException { + if (identityToken == null) { + throw new IllegalArgumentException("토큰이 비어있습니다."); + } + + Map headers = jwtValidator.parseHeaders(identityToken); + ApplePublicKeyResponse applePublicKeys = getAppleAuthPublicKey(); + PublicKey publicKey = applePublicKeyGenerator.generatePublicKey(headers, applePublicKeys); + + try { + return jwtValidator.getTokenClaims(identityToken, publicKey).getSubject(); + } catch (ExpiredJwtException e) { + throw new IllegalArgumentException("토큰이 만료되었습니다.", e); + } + } + + private ApplePublicKeyResponse getAppleAuthPublicKey() throws JsonProcessingException { + String response = restClient.get() + .uri(URI.create(APPLE_PUBLIC_KEY_URL)) + .retrieve() + .body(String.class); + + return objectMapper.readValue(response, ApplePublicKeyResponse.class); + } +} \ No newline at end of file diff --git a/src/main/java/com/groom/swipo/domain/auth/service/KakaoLoginService.java b/src/main/java/com/groom/swipo/domain/auth/service/KakaoLoginService.java index bf739ec..c00f954 100644 --- a/src/main/java/com/groom/swipo/domain/auth/service/KakaoLoginService.java +++ b/src/main/java/com/groom/swipo/domain/auth/service/KakaoLoginService.java @@ -13,7 +13,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; -import com.groom.swipo.domain.auth.dto.response.KakaoLoginResponse; +import com.groom.swipo.domain.auth.dto.response.SocialLoginResponse; import com.groom.swipo.domain.auth.exception.KakaoAuthException; import com.groom.swipo.domain.user.entity.User; import com.groom.swipo.domain.user.entity.enums.Provider; @@ -46,24 +46,24 @@ public class KakaoLoginService { private String redirectUri; @Transactional - public KakaoLoginResponse kakaoLogin(String code) { + public SocialLoginResponse kakaoLogin(String code) { try { String kakaoAccessToken = getKakaoAccessToken(code); String[] userInfo = getKakaoUserInfo(kakaoAccessToken); return userRepository.findByProviderAndProviderId(Provider.KAKAO, userInfo[0]) .map(this::handleExistingUserLogin) - .orElseGet(() -> KakaoLoginResponse.of(userInfo[0], userInfo[1])); + .orElseGet(() -> SocialLoginResponse.of(userInfo[0], userInfo[1])); } catch (JsonProcessingException e) { throw new KakaoAuthException(); } } - private KakaoLoginResponse handleExistingUserLogin(User user) { + private SocialLoginResponse handleExistingUserLogin(User user) { String accessToken = tokenProvider.createAccessToken(user); String refreshToken = tokenProvider.createRefreshToken(user); tokenRenewService.saveRefreshToken(refreshToken, user.getId()); - return KakaoLoginResponse.of(user.getId(), accessToken, refreshToken); + return SocialLoginResponse.of(user.getId(), accessToken, refreshToken); } private String getKakaoAccessToken(String code) throws JsonProcessingException { diff --git a/src/main/java/com/groom/swipo/domain/auth/util/ApplePublicKeyGenerator.java b/src/main/java/com/groom/swipo/domain/auth/util/ApplePublicKeyGenerator.java new file mode 100644 index 0000000..d8c4552 --- /dev/null +++ b/src/main/java/com/groom/swipo/domain/auth/util/ApplePublicKeyGenerator.java @@ -0,0 +1,40 @@ +package com.groom.swipo.domain.auth.util; + +import java.math.BigInteger; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.RSAPublicKeySpec; +import java.util.Base64; +import java.util.Map; + +import javax.naming.AuthenticationException; + +import org.springframework.stereotype.Component; + +import com.groom.swipo.domain.auth.dto.ApplePublicKey; +import com.groom.swipo.domain.auth.dto.response.ApplePublicKeyResponse; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class ApplePublicKeyGenerator { + public PublicKey generatePublicKey(Map tokenHeaders, + ApplePublicKeyResponse applePublicKeys) + throws AuthenticationException, NoSuchAlgorithmException, InvalidKeySpecException { + ApplePublicKey publicKey = applePublicKeys.getMatchedKey(tokenHeaders.get("kid"), + tokenHeaders.get("alg")); + return getPublicKey(publicKey); + } + private PublicKey getPublicKey(ApplePublicKey publicKey) + throws NoSuchAlgorithmException, InvalidKeySpecException { + byte[] nBytes = Base64.getUrlDecoder().decode(publicKey.n()); + byte[] eBytes = Base64.getUrlDecoder().decode(publicKey.e()); + RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(new BigInteger(1, nBytes), + new BigInteger(1, eBytes)); + KeyFactory keyFactory = KeyFactory.getInstance(publicKey.kty()); + return keyFactory.generatePublic(publicKeySpec); + } +} \ No newline at end of file diff --git a/src/main/java/com/groom/swipo/domain/auth/util/JwtValidator.java b/src/main/java/com/groom/swipo/domain/auth/util/JwtValidator.java new file mode 100644 index 0000000..6bbd6aa --- /dev/null +++ b/src/main/java/com/groom/swipo/domain/auth/util/JwtValidator.java @@ -0,0 +1,42 @@ +package com.groom.swipo.domain.auth.util; + +import java.nio.charset.StandardCharsets; +import java.security.PublicKey; +import java.util.Base64; +import java.util.Map; + +import org.springframework.stereotype.Component; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import lombok.RequiredArgsConstructor; + +// 해당 코드는 Jwt 전체에 대해 검증하는 것이 아닌 애플 로그인떄 받아오는 itentity token 검증용 +@Component +public class JwtValidator { + + public Map parseHeaders(String token) throws JsonProcessingException { + if (token == null) { + throw new IllegalArgumentException("토큰이 비어있습니다."); + } + + String header = token.split("\\.")[0]; + return new ObjectMapper().readValue(decodeHeader(header), new TypeReference>() {}); + } + + public String decodeHeader(String token) { + return new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8); + } + + public Claims getTokenClaims(String token, PublicKey publicKey) { + return Jwts.parserBuilder() + .setSigningKey(publicKey) + .build() + .parseClaimsJws(token) + .getBody(); + } +} diff --git a/src/main/java/com/groom/swipo/domain/user/controller/UserController.java b/src/main/java/com/groom/swipo/domain/user/controller/UserController.java index 911ab64..0f5afb0 100644 --- a/src/main/java/com/groom/swipo/domain/user/controller/UserController.java +++ b/src/main/java/com/groom/swipo/domain/user/controller/UserController.java @@ -10,10 +10,12 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.groom.swipo.domain.auth.dto.request.AppleLoginRequest; import com.groom.swipo.domain.auth.dto.request.KakaoLoginRequest; import com.groom.swipo.domain.auth.dto.request.TokenRefreshRequest; -import com.groom.swipo.domain.auth.dto.response.KakaoLoginResponse; +import com.groom.swipo.domain.auth.dto.response.SocialLoginResponse; import com.groom.swipo.domain.auth.dto.response.TokenRefreshResponse; +import com.groom.swipo.domain.auth.service.AppleLoginService; import com.groom.swipo.domain.auth.service.KakaoLoginService; import com.groom.swipo.domain.auth.service.TokenRenewService; import com.groom.swipo.domain.user.dto.request.PhoneCheckRequest; @@ -38,6 +40,7 @@ public class UserController { private final UserService userService; private final SmsService smsService; private final KakaoLoginService kakaoLoginService; + private final AppleLoginService appleLoginService; private final TokenRenewService tokenRenewService; @PostMapping("/kakao") @@ -53,8 +56,29 @@ public class UserController { @ApiResponse(responseCode = "500", description = "서버 오류") } ) - public ResTemplate kakaoLogin(@RequestBody KakaoLoginRequest request) { - KakaoLoginResponse data = kakaoLoginService.kakaoLogin(request.kakaoCode()); + public ResTemplate kakaoLogin(@RequestBody KakaoLoginRequest request) { + SocialLoginResponse data = kakaoLoginService.kakaoLogin(request.kakaoCode()); + if (data.userId() == null) { + return new ResTemplate<>(HttpStatus.I_AM_A_TEAPOT, "회원가입 필요", data); + } + return new ResTemplate<>(HttpStatus.OK, "로그인 성공", data); + } + + @PostMapping("/apple") + @Operation( + summary = "애플 로그인", + description = "애플 identity token을 사용하여 로그인 또는 회원가입 필요 여부를 판별합니다. DB에 사용자 정보가 있으면 로그인 성공, 없으면 회원가입 필요 상태를 반환합니다.", + security = {}, + responses = { + @ApiResponse(responseCode = "200", description = "로그인 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청"), + @ApiResponse(responseCode = "401", description = "인증되지 않은 요청"), + @ApiResponse(responseCode = "418", description = "회원가입 필요"), + @ApiResponse(responseCode = "500", description = "서버 오류") + } + ) + public ResTemplate appleLogin(@RequestBody AppleLoginRequest request) { + SocialLoginResponse data = appleLoginService.appleLogin(request.token()); if (data.userId() == null) { return new ResTemplate<>(HttpStatus.I_AM_A_TEAPOT, "회원가입 필요", data); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 90d9463..b91b737 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -39,6 +39,9 @@ oauth: client-secret: ${oauth.kakao.client-secret} redirect-uri: ${oauth.kakao.redirect-uri} + apple: + public-key-url: ${oauth.apple.public-key-url} + imp: api: key: ${imp.api.key}