diff --git a/user-service/build.gradle.kts b/user-service/build.gradle.kts index 3fde6ce..3cd1fdd 100644 --- a/user-service/build.gradle.kts +++ b/user-service/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { // https://mvnrepository.com/artifact/com.mysql/mysql-connector-j implementation("com.mysql:mysql-connector-j:8.4.0") + implementation("org.springframework:spring-jdbc") implementation("org.springdoc:springdoc-openapi-starter-webflux-ui:2.5.0") implementation("io.asyncer:r2dbc-mysql:1.1.0") @@ -43,6 +44,7 @@ dependencies { implementation("io.jsonwebtoken:jjwt-api:$jjwtVersion") runtimeOnly("io.jsonwebtoken:jjwt-impl:$jjwtVersion") runtimeOnly("io.jsonwebtoken:jjwt-jackson:$jjwtVersion") + implementation("com.nimbusds:nimbus-jose-jwt:9.37.2") implementation("io.projectreactor.tools:blockhound:1.0.9.RELEASE") implementation("io.micrometer:micrometer-tracing-bridge-otel:1.3.2") diff --git a/user-service/src/main/java/kr/mafoo/user/api/AuthApi.java b/user-service/src/main/java/kr/mafoo/user/api/AuthApi.java index c236011..2fe2fd6 100644 --- a/user-service/src/main/java/kr/mafoo/user/api/AuthApi.java +++ b/user-service/src/main/java/kr/mafoo/user/api/AuthApi.java @@ -2,6 +2,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import kr.mafoo.user.controller.dto.request.AppleLoginRequest; import kr.mafoo.user.controller.dto.request.KakaoLoginRequest; import kr.mafoo.user.controller.dto.request.TokenRefreshRequest; import kr.mafoo.user.controller.dto.response.LoginResponse; @@ -21,6 +22,12 @@ Mono loginWithKakao( @RequestBody KakaoLoginRequest request ); + @Operation(summary = "애플 로그인" , description = "애플 인가 코드로 로그인(토큰 발행)합니다.") + @PostMapping("/login/apple") + Mono loginWithApple( + @RequestBody AppleLoginRequest request + ); + @Operation(summary = "토큰 갱신", description = "리프레시 토큰으로 기존 토큰을 갱신합니다.") @PostMapping("/refresh") Mono loginWithRefreshToken( diff --git a/user-service/src/main/java/kr/mafoo/user/config/properties/AppleOAuthProperties.java b/user-service/src/main/java/kr/mafoo/user/config/properties/AppleOAuthProperties.java new file mode 100644 index 0000000..dbed276 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/config/properties/AppleOAuthProperties.java @@ -0,0 +1,11 @@ +package kr.mafoo.user.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.boot.context.properties.ConfigurationPropertiesBinding; + +@ConfigurationProperties(prefix = "app.oauth.apple") +@ConfigurationPropertiesBinding +public record AppleOAuthProperties( + String clientId +) { +} diff --git a/user-service/src/main/java/kr/mafoo/user/controller/AuthController.java b/user-service/src/main/java/kr/mafoo/user/controller/AuthController.java index 8e7649e..b274df3 100644 --- a/user-service/src/main/java/kr/mafoo/user/controller/AuthController.java +++ b/user-service/src/main/java/kr/mafoo/user/controller/AuthController.java @@ -1,6 +1,7 @@ package kr.mafoo.user.controller; import kr.mafoo.user.api.AuthApi; +import kr.mafoo.user.controller.dto.request.AppleLoginRequest; import kr.mafoo.user.controller.dto.request.KakaoLoginRequest; import kr.mafoo.user.controller.dto.request.TokenRefreshRequest; import kr.mafoo.user.controller.dto.response.LoginResponse; @@ -21,6 +22,13 @@ public Mono loginWithKakao(KakaoLoginRequest request) { .map(authToken -> new LoginResponse(authToken.accessToken(), authToken.refreshToken())); } + @Override + public Mono loginWithApple(AppleLoginRequest request) { + return authService + .loginWithApple(request.identityToken()) + .map(authToken -> new LoginResponse(authToken.accessToken(), authToken.refreshToken())); + } + @Override public Mono loginWithRefreshToken(TokenRefreshRequest request) { return authService diff --git a/user-service/src/main/java/kr/mafoo/user/controller/dto/request/AppleLoginRequest.java b/user-service/src/main/java/kr/mafoo/user/controller/dto/request/AppleLoginRequest.java new file mode 100644 index 0000000..e02ae08 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/controller/dto/request/AppleLoginRequest.java @@ -0,0 +1,10 @@ +package kr.mafoo.user.controller.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "애플 로그인 요청") +public record AppleLoginRequest( + @Schema(description = "엑세스 코드", example = "test") + String identityToken +) { +} diff --git a/user-service/src/main/java/kr/mafoo/user/controller/dto/response/AppleKeyListResponse.java b/user-service/src/main/java/kr/mafoo/user/controller/dto/response/AppleKeyListResponse.java new file mode 100644 index 0000000..4490f52 --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/controller/dto/response/AppleKeyListResponse.java @@ -0,0 +1,6 @@ +package kr.mafoo.user.controller.dto.response; + +public record AppleKeyListResponse( + AppleKeyResponse[] keys +) { +} diff --git a/user-service/src/main/java/kr/mafoo/user/controller/dto/response/AppleKeyResponse.java b/user-service/src/main/java/kr/mafoo/user/controller/dto/response/AppleKeyResponse.java new file mode 100644 index 0000000..ccc6a9a --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/controller/dto/response/AppleKeyResponse.java @@ -0,0 +1,11 @@ +package kr.mafoo.user.controller.dto.response; + +public record AppleKeyResponse( + String kty, + String kid, + String use, + String alg, + String n, + String e +) { +} diff --git a/user-service/src/main/java/kr/mafoo/user/controller/dto/response/AppleLoginInfo.java b/user-service/src/main/java/kr/mafoo/user/controller/dto/response/AppleLoginInfo.java new file mode 100644 index 0000000..7cd6f7c --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/controller/dto/response/AppleLoginInfo.java @@ -0,0 +1,6 @@ +package kr.mafoo.user.controller.dto.response; + +public record AppleLoginInfo( + String id +) { +} diff --git a/user-service/src/main/java/kr/mafoo/user/enums/IdentityProvider.java b/user-service/src/main/java/kr/mafoo/user/enums/IdentityProvider.java index 1e05e16..11a616e 100644 --- a/user-service/src/main/java/kr/mafoo/user/enums/IdentityProvider.java +++ b/user-service/src/main/java/kr/mafoo/user/enums/IdentityProvider.java @@ -1,5 +1,6 @@ package kr.mafoo.user.enums; public enum IdentityProvider { - KAKAO + KAKAO, + APPLE } diff --git a/user-service/src/main/java/kr/mafoo/user/service/AuthService.java b/user-service/src/main/java/kr/mafoo/user/service/AuthService.java index 7433cf7..4ace3f6 100644 --- a/user-service/src/main/java/kr/mafoo/user/service/AuthService.java +++ b/user-service/src/main/java/kr/mafoo/user/service/AuthService.java @@ -1,17 +1,32 @@ package kr.mafoo.user.service; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nimbusds.jose.jwk.JWK; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import kr.mafoo.user.config.properties.AppleOAuthProperties; import kr.mafoo.user.config.properties.KakaoOAuthProperties; +import kr.mafoo.user.controller.dto.response.AppleKeyListResponse; +import kr.mafoo.user.controller.dto.response.AppleKeyResponse; +import kr.mafoo.user.controller.dto.response.AppleLoginInfo; import kr.mafoo.user.controller.dto.response.KakaoLoginInfo; import kr.mafoo.user.domain.AuthToken; import kr.mafoo.user.domain.SocialMemberEntity; import kr.mafoo.user.enums.IdentityProvider; import kr.mafoo.user.exception.KakaoLoginFailedException; import kr.mafoo.user.repository.SocialMemberRepository; +import kr.mafoo.user.util.NicknameGenerator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; +import java.security.Key; +import java.security.interfaces.RSAPublicKey; +import java.util.Arrays; +import java.util.Base64; import java.util.LinkedHashMap; @RequiredArgsConstructor @@ -22,6 +37,8 @@ public class AuthService { private final MemberService memberService; private final JWTTokenService jwtTokenService; private final KakaoOAuthProperties kakaoOAuthProperties; + private final AppleOAuthProperties appleOAuthProperties; + private final ObjectMapper objectMapper; public Mono loginWithKakao(String code) { @@ -35,6 +52,17 @@ public Mono loginWithKakao(String code) { )); } + public Mono loginWithApple(String identityToken) { + return getApplePublicKeys() + .flatMap(keyObj -> getUserInfoWithAppleAccessToken(keyObj.keys(), identityToken)) + .flatMap(appleLoginInfo -> getOrCreateMember( + IdentityProvider.APPLE, + appleLoginInfo.id(), + NicknameGenerator.generate(), + null + )); + } + public Mono loginWithRefreshToken(String refreshToken){ return Mono .fromCallable(() -> jwtTokenService.extractUserIdFromRefreshToken(refreshToken)) @@ -100,4 +128,38 @@ private Mono getUserInfoWithKakaoToken(String kakaoToken){ (String) ((LinkedHashMap)map.get("properties")).get("profile_image") )); } + + private Mono getApplePublicKeys(){ + return externalWebClient + .get() + .uri("https://appleid.apple.com/auth/keys") + .retrieve() + .bodyToMono(AppleKeyListResponse.class); + } + + private Mono getUserInfoWithAppleAccessToken(AppleKeyResponse[] keys, String identityToken) { + return Mono.fromCallable(() -> { + String[] tokenParts = identityToken.split("\\."); + String headerPart = new String(Base64.getDecoder().decode(tokenParts[0])); + JsonNode headerNode = objectMapper.readTree(headerPart); + String kid = headerNode.get("kid").asText(); + AppleKeyResponse keyStr = Arrays.stream(keys) + .filter(k -> k.kid().equals(kid)) + .findFirst() + .orElseThrow(() -> new RuntimeException("Apple Key not found")); + + RSAPublicKey pubKey = JWK.parse(objectMapper.writeValueAsString(keyStr)).toRSAKey().toRSAPublicKey(); + Jws claims = Jwts.parser() + .verifyWith(pubKey) + .build() + .parseSignedClaims(identityToken); + + String client = claims.getPayload().get("aud", String.class); + if (!client.equals(appleOAuthProperties.clientId())) { + throw new RuntimeException(); + } + + return new AppleLoginInfo(claims.getPayload().get("sub", String.class)); + }); + } } diff --git a/user-service/src/main/java/kr/mafoo/user/util/NicknameGenerator.java b/user-service/src/main/java/kr/mafoo/user/util/NicknameGenerator.java new file mode 100644 index 0000000..3bdf53e --- /dev/null +++ b/user-service/src/main/java/kr/mafoo/user/util/NicknameGenerator.java @@ -0,0 +1,48 @@ +package kr.mafoo.user.util; + +import lombok.NoArgsConstructor; + +import java.util.concurrent.ThreadLocalRandom; + +@NoArgsConstructor(access = lombok.AccessLevel.PRIVATE) +public class NicknameGenerator { + private static String[] prefixes = new String[] { + "멋있는", "맛있는", "대단한", "엄청난", "위대한", "행복한", "즐거운", "새로운", "작은", "신기한", "재밌는", "놀라운" + }; + private static String[] suffixes = new String[] { "식사", + "과일", + "책", + "커피", + "술", + "음악", + "영화", + "채팅", + "통화", + "코딩", + "휴식", + "여행", + "운동", + "게임", + "독서", + "공부", + "숙제", + "과제", + "취미", + "취업", + "연애", + "결혼", + "투자", + "프로그래밍", + "개발", + "코딩", + "테스트", + "보안" }; + + public static String generate() { + ThreadLocalRandom random = ThreadLocalRandom.current(); + int prefixIndex = random.nextInt(prefixes.length); + int suffixIndex = random.nextInt(suffixes.length); + return prefixes[prefixIndex] + " " + suffixes[suffixIndex]; + } + +} diff --git a/user-service/src/main/resources/application.yaml b/user-service/src/main/resources/application.yaml index 29ed06b..c627c58 100644 --- a/user-service/src/main/resources/application.yaml +++ b/user-service/src/main/resources/application.yaml @@ -23,6 +23,8 @@ app: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} redirect-uri: ${KAKAO_REDIRECT_URL} + apple: + client-id: ${APPLE_CLIENT_ID} jwt: verify-key: ${JWT_VERIFY_KEY} expiration: