diff --git a/.gitignore b/.gitignore index 6a00b9f9..c278e9ad 100644 --- a/.gitignore +++ b/.gitignore @@ -198,7 +198,8 @@ package.json .env application-oauth.yml application.properties +/src/main/resources/firebase-private-key.json +/src/main/resources/muffler-apple-auth-key.p8 # Generated files src/main/generated/ -/src/main/resources/firebase-private-key.json diff --git a/build.gradle b/build.gradle index 91d29c1e..d9384e3d 100644 --- a/build.gradle +++ b/build.gradle @@ -49,6 +49,7 @@ dependencies { compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' + annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" testImplementation 'org.springframework.boot:spring-boot-starter-test' implementation 'com.h2database:h2' diff --git a/src/main/java/com/umc5th/muffler/domain/member/dto/AppleIdToken.java b/src/main/java/com/umc5th/muffler/domain/member/dto/AppleIdToken.java new file mode 100644 index 00000000..c48bf162 --- /dev/null +++ b/src/main/java/com/umc5th/muffler/domain/member/dto/AppleIdToken.java @@ -0,0 +1,8 @@ +package com.umc5th.muffler.domain.member.dto; + +import lombok.Getter; + +@Getter +public class AppleIdToken { + private String sub; +} diff --git a/src/main/java/com/umc5th/muffler/domain/member/dto/AppleToken.java b/src/main/java/com/umc5th/muffler/domain/member/dto/AppleToken.java new file mode 100644 index 00000000..8c5a8f55 --- /dev/null +++ b/src/main/java/com/umc5th/muffler/domain/member/dto/AppleToken.java @@ -0,0 +1,18 @@ +package com.umc5th.muffler.domain.member.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class AppleToken { + @JsonProperty("access_token") + private String accessToken; + @JsonProperty("token_type") + private String tokenType; + @JsonProperty("expires_in") + private String expiresIn; + @JsonProperty("refresh_token") + private String refreshToken; + @JsonProperty("id_token") + private String idToken; +} diff --git a/src/main/java/com/umc5th/muffler/domain/member/dto/KakaoUnlinkResponse.java b/src/main/java/com/umc5th/muffler/domain/member/dto/KakaoUnlinkResponse.java index 34d4e1af..b492829a 100644 --- a/src/main/java/com/umc5th/muffler/domain/member/dto/KakaoUnlinkResponse.java +++ b/src/main/java/com/umc5th/muffler/domain/member/dto/KakaoUnlinkResponse.java @@ -1,12 +1,8 @@ package com.umc5th.muffler.domain.member.dto; -import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.NoArgsConstructor; @Getter -@AllArgsConstructor -@NoArgsConstructor public class KakaoUnlinkResponse { private Long id; } diff --git a/src/main/java/com/umc5th/muffler/domain/member/service/AppleProperties.java b/src/main/java/com/umc5th/muffler/domain/member/service/AppleProperties.java new file mode 100644 index 00000000..2fc29df4 --- /dev/null +++ b/src/main/java/com/umc5th/muffler/domain/member/service/AppleProperties.java @@ -0,0 +1,46 @@ +package com.umc5th.muffler.domain.member.service; + +import java.io.IOException; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import javax.annotation.PostConstruct; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; +import org.springframework.util.FileCopyUtils; + +@Component +@ConfigurationProperties(prefix = "social-login.apple") +@Getter +@Setter +@RequiredArgsConstructor +public class AppleProperties { + private String grantType; + private String clientId; + private String keyId; + private String teamId; + private String audience; + private String privateKeyPath; + private String privateKey; + + private final ResourceLoader resourceLoader; + + @PostConstruct + public void init() throws IOException { + Resource resource = resourceLoader.getResource(privateKeyPath); + try (InputStreamReader reader = new InputStreamReader(resource.getInputStream(), StandardCharsets.UTF_8)) { + this.privateKey = FileCopyUtils.copyToString(reader); + } + this.privateKey = processKey(this.privateKey); + } + + private String processKey(String pemKey) { + return pemKey.replaceAll("-----BEGIN PRIVATE KEY-----", "") + .replaceAll("-----END PRIVATE KEY-----", "") + .replaceAll("\\s", ""); + } +} diff --git a/src/main/java/com/umc5th/muffler/domain/member/service/AppleService.java b/src/main/java/com/umc5th/muffler/domain/member/service/AppleService.java index d5681a4c..9d21c6af 100644 --- a/src/main/java/com/umc5th/muffler/domain/member/service/AppleService.java +++ b/src/main/java/com/umc5th/muffler/domain/member/service/AppleService.java @@ -1,13 +1,67 @@ package com.umc5th.muffler.domain.member.service; +import static com.umc5th.muffler.global.response.code.ErrorCode.INTERNAL_SERVER_ERROR; + +import com.umc5th.muffler.domain.member.dto.AppleIdToken; import com.umc5th.muffler.domain.member.dto.LoginRequest; +import com.umc5th.muffler.global.feign.AppleClient; +import com.umc5th.muffler.global.response.exception.MemberException; +import com.umc5th.muffler.global.security.jwt.JwtDecoder; +import com.umc5th.muffler.global.util.DateTimeProvider; +import io.jsonwebtoken.JwsHeader; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import java.security.PrivateKey; +import java.security.Security; +import java.util.Base64; import lombok.RequiredArgsConstructor; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor public class AppleService { + private final AppleClient appleClient; + private final AppleProperties appleProperties; + private final DateTimeProvider dateTimeProvider; + public String login(LoginRequest request) { - return request.getIdToken(); + String authentication = request.getIdToken(); + String idToken = appleClient.getIdToken( + appleProperties.getClientId(), + generateClientSecret(), + appleProperties.getGrantType(), + authentication + ).getIdToken(); + + return JwtDecoder.decodePayload(idToken, AppleIdToken.class).getSub(); + } + + private String generateClientSecret() { + return Jwts.builder() + .setHeaderParam(JwsHeader.KEY_ID, appleProperties.getKeyId()) + .setIssuer(appleProperties.getTeamId()) + .setAudience(appleProperties.getAudience()) + .setSubject(appleProperties.getClientId()) + .setExpiration(dateTimeProvider.getDateAfterMinutes(5)) + .setIssuedAt(dateTimeProvider.getIssuedDate()) + .signWith(getPrivateKey(), SignatureAlgorithm.ES256) + .compact(); + } + + private PrivateKey getPrivateKey() { + Security.addProvider(new BouncyCastleProvider()); + JcaPEMKeyConverter converter = new JcaPEMKeyConverter().setProvider("BC"); + + try { + byte[] privateKeyBytes = Base64.getDecoder().decode(appleProperties.getPrivateKey()); + PrivateKeyInfo privateKeyInfo = PrivateKeyInfo.getInstance(privateKeyBytes); + return converter.getPrivateKey(privateKeyInfo); + } catch (Exception e) { + e.printStackTrace(); + throw new MemberException(INTERNAL_SERVER_ERROR, "String 타입 ApplePrivateKey convert 중 에러 발생"); + } } } diff --git a/src/main/java/com/umc5th/muffler/domain/member/service/KakaoService.java b/src/main/java/com/umc5th/muffler/domain/member/service/KakaoService.java index 6344adc7..1c4cde1e 100644 --- a/src/main/java/com/umc5th/muffler/domain/member/service/KakaoService.java +++ b/src/main/java/com/umc5th/muffler/domain/member/service/KakaoService.java @@ -32,6 +32,7 @@ public String login(String idToken) { public void leave(String memberId) { try { kakaoClient.unlinkMember(Long.valueOf(memberId)); + log.info("아이디 {}의 카카오 계정 연결 끊기가 완료되었습니다.", memberId); } catch (FeignException e) { String errorResult = e.getErrorResult(); if (!errorResult.contains(KAKAO_LEAVE_EXCEPTION)) { diff --git a/src/main/java/com/umc5th/muffler/global/feign/AppleClient.java b/src/main/java/com/umc5th/muffler/global/feign/AppleClient.java new file mode 100644 index 00000000..527ecfb0 --- /dev/null +++ b/src/main/java/com/umc5th/muffler/global/feign/AppleClient.java @@ -0,0 +1,20 @@ +package com.umc5th.muffler.global.feign; + +import com.umc5th.muffler.domain.member.dto.AppleToken; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; + +@FeignClient( + name = "apple-client", + url = "https://appleid.apple.com" +) +public interface AppleClient { + @PostMapping("/auth/token") + AppleToken getIdToken( + @RequestParam("client_id") String clientId, + @RequestParam("client_secret") String clientSecret, + @RequestParam("grant_type") String grantType, + @RequestParam("code") String code + ); +} diff --git a/src/main/java/com/umc5th/muffler/global/feign/KakaoClient.java b/src/main/java/com/umc5th/muffler/global/feign/KakaoClient.java index f12c6112..002fbf62 100644 --- a/src/main/java/com/umc5th/muffler/global/feign/KakaoClient.java +++ b/src/main/java/com/umc5th/muffler/global/feign/KakaoClient.java @@ -5,9 +5,12 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; -@FeignClient(name = "kakao-client", url = "https://kapi.kakao.com", configuration = {KakaoAuthInterceptor.class}) +@FeignClient( + name = "kakao-client", + url = "https://kapi.kakao.com", + configuration = {KakaoAuthInterceptor.class} +) public interface KakaoClient { - @PostMapping("/v1/user/unlink?target_id_type=user_id") KakaoUnlinkResponse unlinkMember(@RequestParam(value = "target_id") Long memberId); } diff --git a/src/main/java/com/umc5th/muffler/global/response/code/ErrorCode.java b/src/main/java/com/umc5th/muffler/global/response/code/ErrorCode.java index 59fbbe02..5d168d65 100644 --- a/src/main/java/com/umc5th/muffler/global/response/code/ErrorCode.java +++ b/src/main/java/com/umc5th/muffler/global/response/code/ErrorCode.java @@ -15,26 +15,23 @@ public enum ErrorCode { FORBIDDEN(HttpStatus.FORBIDDEN, "금지된 요청입니다."), INVALID_PERMISSION(HttpStatus.UNAUTHORIZED, "권한이 유효하지 않습니다."), INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "토큰이 유효하지 않습니다."), - + TOKEN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "토큰 에러입니다."), // Member 에러 MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 사용자입니다."), UNSUPPORTED_SOCIAL_TYPE(HttpStatus.UNSUPPORTED_MEDIA_TYPE,"지원하지 않는 소셜 로그인 입니다."), - // Goal 에러 GOAL_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 목표입니다."), INVALID_GOAL_INPUT(HttpStatus.BAD_REQUEST, "올바르지 않은 목표 입력입니다."), NO_GOAL_IN_GIVEN_DATE(HttpStatus.BAD_REQUEST, "해당 날짜에 일치하는 목표가 없습니다"), CATEGORY_GOAL_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 카테고리 목표입니다."), - // DailyPlan 에러 DAILYPLAN_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 일일 소비 계획입니다."), NO_DAILY_PLAN_GIVEN_DATE(HttpStatus.NOT_FOUND, "수정하려는 소비날짜에 맞는 일일 목표가 없습니다. " + "목표가 설정된 다른 날로 수정을 하거나 수정하려는 날에 목표를 설정해주세요."), - // Category 에러 CATEGORY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 카테고리입니다."), DUPLICATED_CATEGORY_NAME(HttpStatus.BAD_REQUEST, "카테고리 이름이 중복되었습니다."), @@ -52,7 +49,6 @@ public enum ErrorCode { CANNOT_UPDATE_OTHER_MEMBER_EXPENSE(HttpStatus.UNAUTHORIZED, "다른 유저의 소비 내역을 수정할 수 없습니다"), CANNOT_UPDATE_TO_ZERO_DAY(HttpStatus.CONFLICT, "제로 데이로 지정한 날로는 소비 내역을 옮길 수 없습니다."), - // Routine 에러 INVALID_ROUTINE_INPUT(HttpStatus.BAD_REQUEST, "올바르지 않은 루틴 입력입니다."), ROUTINE_TYPE_NOT_FOUND(HttpStatus.BAD_REQUEST, "루틴 타입이 없거나 유효하지 않습니다."), diff --git a/src/main/java/com/umc5th/muffler/global/security/jwt/JwtDecoder.java b/src/main/java/com/umc5th/muffler/global/security/jwt/JwtDecoder.java new file mode 100644 index 00000000..78d3536a --- /dev/null +++ b/src/main/java/com/umc5th/muffler/global/security/jwt/JwtDecoder.java @@ -0,0 +1,27 @@ +package com.umc5th.muffler.global.security.jwt; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.umc5th.muffler.global.response.code.ErrorCode; +import com.umc5th.muffler.global.response.exception.CommonException; +import java.util.Base64; +import java.util.Base64.Decoder; + +public class JwtDecoder { + private JwtDecoder() {} + + public static T decodePayload(String token, Class targetClass) { + String[] tokenParts = token.split("\\."); + String payloadJWT = tokenParts[1]; + Decoder decoder = Base64.getUrlDecoder(); + String payload = new String(decoder.decode(payloadJWT)); + ObjectMapper objectMapper = new ObjectMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + try { + return objectMapper.readValue(payload, targetClass); + } catch (Exception e) { + throw new CommonException(ErrorCode.TOKEN_ERROR, "JWT 토큰 페이로드 decode 중 에러 발생"); + } + } +} diff --git a/src/main/java/com/umc5th/muffler/global/util/DateTimeProvider.java b/src/main/java/com/umc5th/muffler/global/util/DateTimeProvider.java index 7dee31b0..01436fc6 100644 --- a/src/main/java/com/umc5th/muffler/global/util/DateTimeProvider.java +++ b/src/main/java/com/umc5th/muffler/global/util/DateTimeProvider.java @@ -5,5 +5,7 @@ public interface DateTimeProvider { LocalDate nowDate(); + Date getIssuedDate(); Date getDateAfterDays(int duration); + Date getDateAfterMinutes(int minutes); } diff --git a/src/main/java/com/umc5th/muffler/global/util/DefaultDateTimeProvider.java b/src/main/java/com/umc5th/muffler/global/util/DefaultDateTimeProvider.java index 470ae12c..8891a892 100644 --- a/src/main/java/com/umc5th/muffler/global/util/DefaultDateTimeProvider.java +++ b/src/main/java/com/umc5th/muffler/global/util/DefaultDateTimeProvider.java @@ -17,6 +17,11 @@ public LocalDate nowDate() { return LocalDate.now(ZoneId.of(ZONE_ID)); } + @Override + public Date getIssuedDate() { + return Date.from(ZonedDateTime.now(ZoneId.of(ZONE_ID)).toInstant()); + } + /** * @return (서울 시간대의 현재 시각 + duration 일) 후의 {@code Date} 반환 */ @@ -26,4 +31,11 @@ public Date getDateAfterDays(int duration) { ZonedDateTime.now(ZoneId.of(ZONE_ID)) .plus(duration, ChronoUnit.DAYS).toInstant()); } + + @Override + public Date getDateAfterMinutes(int minutes) { + return Date.from( + ZonedDateTime.now(ZoneId.of(ZONE_ID)) + .plus(minutes, ChronoUnit.MINUTES).toInstant()); + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 6e779233..e7721df9 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -76,6 +76,14 @@ jwt: aud: ${JWT_AUD} kakao: admin-key: ${KAKAO_APP_ADMIN} +social-login: + apple: + grant-type: authorization_code + client-id: ${APPLE_CLIENT_ID} + key-id: ${APPLE_KEY_ID} + team-id: ${APPLE_TEAM_ID} + audience: https://appleid.apple.com + private-key-path: "classpath:muffler-apple-auth-key.p8" server: servlet: diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index c2d1decf..874c47b9 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -67,6 +67,14 @@ jwt: aud: testAUD kakao: admin-key: testAdmin +social-login: + apple: + grant-type: authorization_code + client-id: testAppleClientId + key-id: testAppleKeyId + team-id: testAppleTeamId + audience: https://appleid.apple.com + private-key-path: "classpath:muffler-apple-auth-key.p8" server: servlet: encoding: