Skip to content

Commit

Permalink
merge: 애플 Oauth 연동
Browse files Browse the repository at this point in the history
Feature/#41 애플 oauth 연동
  • Loading branch information
hong-sile authored Aug 13, 2024
2 parents c658b4a + c77a67a commit b8d95b0
Show file tree
Hide file tree
Showing 12 changed files with 272 additions and 3 deletions.
6 changes: 6 additions & 0 deletions src/main/java/play/pluv/config/RestClientConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.springframework.web.client.RestClient.Builder;
import org.springframework.web.client.support.RestClientAdapter;
import org.springframework.web.service.invoker.HttpServiceProxyFactory;
import play.pluv.oauth.apple.AppleApiClient;
import play.pluv.oauth.google.GoogleApiClient;
import play.pluv.oauth.spotify.SpotifyApiClient;

Expand All @@ -31,6 +32,11 @@ public SpotifyApiClient spotifyApiClient() {
return createHttpInterface(SpotifyApiClient.class);
}

@Bean
public AppleApiClient appleApiClient() {
return createHttpInterface(AppleApiClient.class);
}

@Bean
public GoogleApiClient googleApiClient() {
return createHttpInterface(GoogleApiClient.class);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package play.pluv.login.application.dto;

import jakarta.validation.constraints.NotBlank;

public record AppleLoginRequest(@NotBlank String idToken) {

}
19 changes: 19 additions & 0 deletions src/main/java/play/pluv/login/controller/LoginController.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package play.pluv.login.controller;

import static play.pluv.playlist.domain.MusicStreaming.APPLE;
import static play.pluv.playlist.domain.MusicStreaming.SPOTIFY;
import static play.pluv.playlist.domain.MusicStreaming.YOUTUBE;

Expand All @@ -11,6 +12,7 @@
import play.pluv.base.BaseResponse;
import play.pluv.login.application.JwtProvider;
import play.pluv.login.application.LoginService;
import play.pluv.login.application.dto.AppleLoginRequest;
import play.pluv.login.application.dto.GoogleLoginRequest;
import play.pluv.login.application.dto.JwtMemberId;
import play.pluv.login.application.dto.LoginResponse;
Expand Down Expand Up @@ -56,4 +58,21 @@ public BaseResponse<String> addSpotifyLoginWay(
loginService.addOtherLoginWay(SPOTIFY, jwtMemberId.memberId(), loginRequest.accessToken());
return BaseResponse.ok("");
}

@PostMapping("/login/apple")
public BaseResponse<LoginResponse> loginApple(
@Valid @RequestBody final AppleLoginRequest loginRequest
) {
final var memberId = loginService.createToken(APPLE, loginRequest.idToken());
final var loginResponse = new LoginResponse(jwtProvider.createAccessTokenWith(memberId));
return BaseResponse.ok(loginResponse);
}

@PostMapping("/login/apple/add")
public BaseResponse<String> addAppleLoginWay(
@Valid @RequestBody final AppleLoginRequest loginRequest, final JwtMemberId jwtMemberId
) {
loginService.addOtherLoginWay(APPLE, jwtMemberId.memberId(), loginRequest.idToken());
return BaseResponse.ok("");
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package play.pluv.login.exception;

import static org.springframework.http.HttpStatus.BAD_REQUEST;
import static org.springframework.http.HttpStatus.INTERNAL_SERVER_ERROR;
import static org.springframework.http.HttpStatus.NOT_FOUND;
import static org.springframework.http.HttpStatus.UNAUTHORIZED;

Expand All @@ -15,6 +16,7 @@ public enum LoginExceptionType implements BaseExceptionType {

PLAYLIST_PROVIDER_NOT_FOUND(NOT_FOUND, "지원하지 않는 스트리밍 서비스입니다"),
INVALID_ACCESS_TOKEN(UNAUTHORIZED, "토큰이 유효하지 않습니다."),
GENERATE_APPLE_CLIENT_SECRET_ERROR(INTERNAL_SERVER_ERROR, "private key를 만드는데 오류가 발생했습니다."),
NOT_FOUND_AUTHORIZATION_TOKEN(BAD_REQUEST, "인증 토큰을 찾을 수 없습니다."),
INVALID_ACCESS_TOKEN_TYPE(BAD_REQUEST, "Access Token Type이 올바르지 않습니다.");

Expand Down
14 changes: 14 additions & 0 deletions src/main/java/play/pluv/oauth/apple/AppleApiClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package play.pluv.oauth.apple;

import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED_VALUE;

import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.service.annotation.PostExchange;
import play.pluv.oauth.apple.dto.AppleTokenResponse;

public interface AppleApiClient {

@PostExchange(url = "https://appleid.apple.com/auth/oauth2/v2/token", contentType = APPLICATION_FORM_URLENCODED_VALUE)
AppleTokenResponse fetchToken(@RequestParam final MultiValueMap<String, String> params);
}
14 changes: 14 additions & 0 deletions src/main/java/play/pluv/oauth/apple/AppleConfigProperty.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package play.pluv.oauth.apple;

import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "apple")
public record AppleConfigProperty(
String keyId,
String teamId,
String clientId,
String redirectUri,
String privateKey
) {

}
108 changes: 108 additions & 0 deletions src/main/java/play/pluv/oauth/apple/AppleConnector.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
package play.pluv.oauth.apple;

import static io.jsonwebtoken.io.Decoders.BASE64;
import static java.lang.System.currentTimeMillis;
import static java.util.concurrent.TimeUnit.MILLISECONDS;
import static java.util.concurrent.TimeUnit.MINUTES;
import static play.pluv.login.exception.LoginExceptionType.GENERATE_APPLE_CLIENT_SECRET_ERROR;
import static play.pluv.playlist.domain.MusicStreaming.APPLE;

import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Jwts;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.interfaces.ECPrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Date;
import java.util.Map;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import play.pluv.login.exception.LoginException;
import play.pluv.oauth.apple.dto.AppleTokenResponse;
import play.pluv.oauth.application.SocialLoginClient;
import play.pluv.oauth.domain.OAuthMemberInfo;
import play.pluv.playlist.domain.MusicStreaming;

@Component
@RequiredArgsConstructor
public class AppleConnector implements SocialLoginClient {

private static final String AUDIENCE = "https://appleid.apple.com";
private static final Long EXP = MILLISECONDS.convert(30, MINUTES);

private final ObjectMapper objectMapper;
private final AppleApiClient appleApiClient;
private final AppleConfigProperty appleConfigProperty;

@Override
public OAuthMemberInfo fetchMember(final String idToken) {
final String userIdentifier = extractSub(idToken);
return new OAuthMemberInfo(userIdentifier, APPLE);
}

public AppleTokenResponse geTokens(final String authCode) {
final MultiValueMap<String, String> param = createRequestParamForAccessToken(authCode);
return appleApiClient.fetchToken(param);
}

private String extractSub(final String idToken) {
try {
final String claimsBase64 = idToken.substring(idToken.indexOf('.') + 1,
idToken.lastIndexOf('.'));
final var decode = BASE64.decode(claimsBase64);
final Map<String, Object> claims = objectMapper.readValue(decode, new TypeReference<>() {
});
return (String) claims.get("sub");
} catch (final Exception exception) {
throw new IllegalArgumentException("토큰이 유효하지 않습니다.");
}
}

@Override
public MusicStreaming supportedType() {
return APPLE;
}

private MultiValueMap<String, String> createRequestParamForAccessToken(final String authCode) {
final MultiValueMap<String, String> param = new LinkedMultiValueMap<>();
param.add("grant_type", "authorization_code");
param.add("code", authCode);
param.add("redirect_uri", appleConfigProperty.redirectUri());
param.add("client_id", appleConfigProperty.clientId());
param.add("client_secret", generateClientSecret());
return param;
}

private String generateClientSecret() {
final Map<String, Object> jwtHeaders = Map.of(
"kid", appleConfigProperty.keyId(),
"alg", "ES256"
);

return Jwts.builder()
.header().add(jwtHeaders).and()
.issuer(appleConfigProperty.teamId())
.issuedAt(new Date())
.audience().add(AUDIENCE).and()
.expiration(new Date(EXP + currentTimeMillis()))
.subject(appleConfigProperty.clientId())
.signWith(getPrivateKeyFromPem(appleConfigProperty.privateKey()))
.compact();
}

private static ECPrivateKey getPrivateKeyFromPem(final String privateKey) {
try {
final byte[] decoded = Base64.getDecoder().decode(privateKey);
final PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(decoded);
final KeyFactory keyFactory = KeyFactory.getInstance("EC");
return (ECPrivateKey) keyFactory.generatePrivate(keySpec);
} catch (final NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new LoginException(GENERATE_APPLE_CLIENT_SECRET_ERROR);
}
}
}
12 changes: 12 additions & 0 deletions src/main/java/play/pluv/oauth/apple/dto/AppleTokenResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package play.pluv.oauth.apple.dto;

import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy;
import com.fasterxml.jackson.databind.annotation.JsonNaming;

@JsonNaming(SnakeCaseStrategy.class)
public record AppleTokenResponse(
String accessToken,
String idToken
) {

}
15 changes: 14 additions & 1 deletion src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ google:
client-id: local-id
client-secret: local-secret
redirectUri: http://localhost:8080
apple:
key-id: local
client-id: local
private-key: local
redirect-uri: local
team-id: local
restClient:
logging: true
jwt:
Expand All @@ -31,4 +37,11 @@ spring:
config:
import: classpath:secret/application-prod.yml
activate:
on-profile: prod
on-profile: prod

---
spring:
config:
import: classpath:secret/application-dev.yml
activate:
on-profile: dev
2 changes: 1 addition & 1 deletion src/main/resources/secret
68 changes: 67 additions & 1 deletion src/test/java/play/pluv/api/LoginApiTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import static org.mockito.Mockito.when;
import static org.springframework.http.HttpHeaders.AUTHORIZATION;
import static org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import static org.springframework.restdocs.headers.HeaderDocumentation.headerWithName;
import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders;
import static org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders.post;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessRequest;
import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse;
Expand All @@ -14,10 +16,12 @@
import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields;
import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
import static play.pluv.playlist.domain.MusicStreaming.APPLE;
import static play.pluv.playlist.domain.MusicStreaming.SPOTIFY;
import static play.pluv.playlist.domain.MusicStreaming.YOUTUBE;

import org.junit.jupiter.api.Test;
import play.pluv.login.application.dto.AppleLoginRequest;
import play.pluv.login.application.dto.GoogleLoginRequest;
import play.pluv.login.application.dto.SpotifyLoginRequest;
import play.pluv.support.ApiTest;
Expand Down Expand Up @@ -76,6 +80,31 @@ public class LoginApiTest extends ApiTest {
));
}

@Test
void 애플_소셜_로그인을_한다() throws Exception {
final AppleLoginRequest loginRequest = new AppleLoginRequest("idToken");

final String requestBody = objectMapper.writeValueAsString(loginRequest);

when(loginService.createToken(APPLE, "idToken")).thenReturn(2L);
setCreateToken("idToken", 2L);

mockMvc.perform(post("/login/apple")
.contentType(APPLICATION_JSON_VALUE)
.content(requestBody))
.andExpect(status().isOk())
.andDo(document("apple-login",
requestFields(
fieldWithPath("idToken").type(STRING).description("스포티파이의 accessToken")
),
responseFields(
fieldWithPath("code").type(NUMBER).description("상태 코드"),
fieldWithPath("msg").type(STRING).description("상태 코드에 해당하는 메시지"),
fieldWithPath("data.token").type(STRING).description("로그인 할 떄 쓸 accessToken")
)
));
}

@Test
void 구글_소셜_로그인_방법을_추가한다() throws Exception {
final Long memberId = 10L;
Expand All @@ -93,8 +122,11 @@ public class LoginApiTest extends ApiTest {
.andDo(document("google-login-add",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
requestHeaders(
headerWithName(AUTHORIZATION).description("Bearer Token")
),
requestFields(
fieldWithPath("idToken").type(STRING).description("구글의 accessToken")
fieldWithPath("idToken").type(STRING).description("구글의 idToken")
),
responseFields(
fieldWithPath("code").type(NUMBER).description("상태 코드"),
Expand All @@ -121,6 +153,9 @@ public class LoginApiTest extends ApiTest {
.andDo(document("spotify-login-add",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
requestHeaders(
headerWithName(AUTHORIZATION).description("Bearer Token")
),
requestFields(
fieldWithPath("accessToken").type(STRING).description("스포티파이의 accessToken")
),
Expand All @@ -131,4 +166,35 @@ public class LoginApiTest extends ApiTest {
)
));
}

@Test
void 애플_소셜_로그인_방법을_추가한다() throws Exception {
final String token = "access Token";
final Long memberId = 10L;
final AppleLoginRequest loginRequest = new AppleLoginRequest("idToken");

final String requestBody = objectMapper.writeValueAsString(loginRequest);
setAccessToken(token, memberId);

mockMvc.perform(post("/login/apple/add")
.contentType(APPLICATION_JSON_VALUE)
.content(requestBody)
.header(AUTHORIZATION, "Bearer " + token))
.andExpect(status().isOk())
.andDo(document("apple-login-add",
preprocessRequest(prettyPrint()),
preprocessResponse(prettyPrint()),
requestHeaders(
headerWithName(AUTHORIZATION).description("Bearer Token")
),
requestFields(
fieldWithPath("idToken").type(STRING).description("apple의 idToken")
),
responseFields(
fieldWithPath("code").type(NUMBER).description("상태 코드"),
fieldWithPath("msg").type(STRING).description("상태 코드에 해당하는 메시지"),
fieldWithPath("data").type(STRING).description("")
)
));
}
}
Loading

0 comments on commit b8d95b0

Please sign in to comment.