기록하기

[사이드 프로젝트 - 비사이드] 애플 로그인 & 탈퇴 구현 본문

Server/Spring Boot

[사이드 프로젝트 - 비사이드] 애플 로그인 & 탈퇴 구현

jjungdev 2023. 1. 5. 15:57
  1. Spring Boot + JPA + Querydsl 적용
  2. 네이버 클라우드 Global DNS, SSL 설정
  3. 애플 로그인 구현(현재글)

 

이제 마지막으로 애플 로그인 구현한 것을 정리해보고자 한다.

애플 로그인은 소셜 로그인 연동 중에서 가장 어려움이 큰 기능이다. 레퍼런스도 다른 소셜 로그인보다 부족한 편이고, 공식 문서에서 해결법을 찾기도 어려움이 있다.

이번에는 이전에 구성한 여러 방법들과 더불어 주요하게 도움을 받은 여러 블로그들이 있는데 이런 전체적인 내용을 정리해보려고 한다.

 

애플 로그인 전체적인 구조

로그인 로직을 구현하는데 이 블로그에 의하면 크게 2가지가 있다고 한다.

그 중에서 필자가 선택한 방법은 아래와 같다.

그러면 가장 먼저 회원가입 요청이 들어온 뒤 App 에서 authorizationCode 와 identityToken 을 넘겨 받았다고 가정해보자 

 

1) 회원가입 요청

@PostMapping("/signin/apple")
@ApiOperation(value = "애플 로그인", notes = "애플 로그인 후 받은 authorizationCode 값과 identityToken 을 전달받습니다.")
public ResponseEntity<SignInResponse> appleSignIn(@RequestParam(value = "authorizationCode") String code,
                                                  @RequestParam(value = "identityToken") String identityToken) {
    SignInResponse result = userFacade.appleSignIn(code, identityToken);
    return ResponseEntity.ok().body(result);
}

요청을 받으면 이제 진행해야할 것은, identityToken 을 검증하고 -> authorizationCode 로 apple 서버로부터 token 을 받아온 뒤 -> token 중에 refresh_token 을 검증하면 로그인 과정이 끝난다. 이렇게 작성해보면 간단해보이지만 하다보면 알 수 없는 에러들도 많이 만나게 된다. 이런 에러와 주의 사항은 중간에 추가로 작성을 해보겠다.

자 그러면 identityToken 을 검증해보러 가보자 

 

2) identityToken 검증 - 서명 검증용 public key 요청

다음 로직들을 진행하기 전에 이번에 FeignClient 를 사용해보았다. 그래서 관련 설정과 클래스를 먼저 설정하고자 한다.

먼저, build.gradle 에 아래 내용을 추가해줘야 한다. 

ext {
    springCloudVersion = '2021.0.3'
}

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
    }
}

dependencies {
    ``` 생략
    
    implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
}

 

그리고 application.yml 에 log level 을 설정해줄 수 있는데, 현재 프로젝트에서는 애플 로그인과 관련해서만 사용하기 때문에 해당 클래스 전체에 같은 log level 을 적용했다. 

logging:
  level:
    com.example.apple.util: DEBUG

 

FeignConfig

아래 설정 파일뿐만 아니라 Application 클래스에 @EnableFeignClients 이 어노테이션을 추가해줘야 설정이 완료된다. 

import feign.Logger;
import org.springframework.context.annotation.Bean;

public class FeignConfig {

    @Bean
    Logger.Level feignLoggerLevel() {
        return Logger.Level.FULL;
    }
}

 

 

설정이 완료된 후 AppleClient 라는 클래스를 생성했다. 이 클래스에서는 apple 서버에 요청을 보내고 응답을 받아오는 메소드만 정의하여 토큰 요청이나 탈퇴 요청과 같은 요청을 보낼 때 해당 메소드를 활용할 수 있도록 구성했다.

@FeignClient(name = "appleClient", url = "https://appleid.apple.com/auth", configuration = FeignConfig.class)
public interface AppleClient {
    @GetMapping(value = "/keys")
    ApplePublicKeyResponse findAppleAuthPublicKeys();

    @PostMapping(value = "/token", consumes = APPLICATION_FORM_URLENCODED_VALUE)
    AppleTokenResponse findAppleToken(@RequestBody AppleTokenRequest request);

    @PostMapping(value = "/revoke", consumes = "application/x-www-form-urlencoded")
    void revoke(AppleRevokeRequest request);
}

 

설정 후 이제 public key 요청을 보내보면 findAppleAuthPublicKey() 메소드를 활용할 수 있다.

public Claims verifyIdentityToken(String identityToken) {
    try {
        ApplePublicKeyResponse response = appleFeignClient.findAppleAuthPublicKeys();

        String identityTokenHeader = identityToken.substring(0, identityToken.indexOf("."));

        //identityTokenHeader decode
        String decodedIdentityTokenHeader = new String(Base64.getDecoder().decode(identityTokenHeader), StandardCharsets.UTF_8);

        Map<String, String> identityTokenHeaderMap = objectMapper.readValue(decodedIdentityTokenHeader, Map.class);
        AppleKeyInfo appleKeyInfo = response.keys().stream()
                .filter(key -> Objects.equals(key.getKid(), identityTokenHeaderMap.get("kid"))
                        && Objects.equals(key.getAlg(), identityTokenHeaderMap.get("alg")))
                .findFirst()
                .orElseThrow(() -> new ApplePublicKeyException(APPLE_PUBLIC_KEY_ERROR));

        byte[] nBytes = Base64.getUrlDecoder().decode(appleKeyInfo.getN());
        byte[] eBytes = Base64.getUrlDecoder().decode(appleKeyInfo.getE());

        BigInteger n = new BigInteger(1, nBytes);
        BigInteger e = new BigInteger(1, eBytes);

        RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e);
        KeyFactory keyFactory = KeyFactory.getInstance(appleKeyInfo.getKty());
        PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);

        return Jwts.parserBuilder()
                .setSigningKey(publicKey)
                .build()
                .parseClaimsJws(identityToken)
                .getBody();
    } catch (Exception e) {
        throw new AppleTokenValidationException(APPLE_TOKEN_VALIDATION_ERROR);
    }
}

 

ApplePublicKeyResponse

@Builder
public record ApplePublicKeyResponse(

        @Schema(description = "애플 key response 리스트") List<AppleKeyInfo> keys
) {
}

 

AppleKeyInfo

@Getter
@NoArgsConstructor
public class AppleKeyInfo {

    private String kty;
    private String kid;
    private String use;
    private String alg;
    private String n;
    private String e;
}

 

 

3) public key 응답

요청을 보내면, 아래와 같이 응답이 오는데 kid, alg 값이 매칭이 되는 Key 값 하나로 n, e 값을 활용하여 public key 를 생성한다. 실제 값은 유출할 수 없기 때문에 구조만 따로 그려보았다.

 

4) 서명 검증 이후 apple 고유 id 획득

위 로직에서 error 가 없었다면 처음 요청에서 전달 받았던 authorizationCode 로 token 요청을 보낼 수 있다. (refresh_token 은 다른 요청에서 필요하여 같은 DTO 에 작성해두었으며 지금 이 요청에서는 무시해도 된다.)

@Getter
@Builder
@ToString
public class AppleTokenRequest {

    private String client_id;
    private String client_secret;
    private String code;
    private String grant_type;
    private String refresh_token;
}
  • client_id : APP Bundle ID
  • client_secret : 아래에서 설명
  • code : authorizationCode
  • grant_type : "authorization_code"

 

여기서 주의할 것이 하나 있다. 가장 오래 오류를 해결하지 못한 구간이 여기 getAppleToken 쪽인데 문제는 2개였다. 

먼저, client_id 부분이다. 여러 블로그에서는 client_id 가 APP Bundle ID 라고 말씀하시는 분도 있고, Service ID 라고 말씀하시는 분들이 있는데 필자처럼 잘 모르는 사람의 경우 이 내용만 보면 혼란이 오기 마련이다.

그래서 찾아보니 아래와 같이 설명해주는 내용을 찾았고 이를 통해 필자의 경우 App 에서 code 를 받기 때문에 APP Bundle ID 를 작성하면 된다는 것을 알게 되었다.

For him, the service id is the correct one. Because he is getting the code for web login. But when you use an app to get the code, you should use bundle id instead.

 

public AppleTokenResponse getAppleToken(String code) {
    AppleTokenRequest appleTokenRequest = AppleTokenRequest.builder()
            .clientId("clientId 넣기")
            .clientSecret(createClientSecret())
            .code(code)
            .grantType(AUTHORIZATION_CODE)
            .build();

    return appleFeignClient.findAppleToken(appleTokenRequest);
}

 

5) client_secret 생성

그리고 client_secret 요청은 이렇게 작성을 했는데, 여기서 또 다른 문제를 만났다.

public String createSecret() {
    Date expirationDate = Date.from(LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant());
    try {
        return Jwts.builder()
                .setHeaderParam("kid", keyId)
                .setHeaderParam("alg", "ES256")
                .setIssuer(teamId)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(expirationDate)
                .setAudience(authUrl)
                .setSubject(clientId)
                .signWith(this.getPrivateKey(), SignatureAlgorithm.ES256)
                .compact();
    } catch (IOException e) {
        throw new RuntimeException(e);
    }
}

 

애플에서 권고하기를 유효기간을 최대 30일까지 설정하도록 제한이 되어 있기 때문에 그 이상으로 설정을 하면 오류가 발생한다. 이 부분에서 가장 오래 해맸는데 토큰 요청에서 invalid_client 오류가 계속 나타났다. 여러 공식 블로그에서 오류를 점검해보고 + 토큰에 이상이 있는 것이 아닌지 jwt 사이트에서도 검증을 해보았는데 도저히 찾을 수가 없었다.

결국 찾은 문제는 유효기간을 처음에 30일 보다 더 큰 값으로 설정을 했었는데 이 곳에서 발생한 오류였던 것이다. 그래서 이 부분은 꼭 주의해주셨으면 하며, 그때 찾아보았던 Apple Developer 포럼 링크들을 같이 첨부한다.

- 참고 링크 목록

https://fluffy.es/how-to-solve-invalid_client-error-in-sign-in-with-apple/

https://developer.apple.com/forums/thread/124521

https://developer.apple.com/forums/thread/685927

 

6) 로그인에 사용될 token 요청, 7) token(access_token, refresh_token)

그러면 이제 위에서 작성한 AppleClient 에서 token 요청 메소드를 활용하여 요청을 보낼 수 있고, 응답 DTO 는 아래와 같이 작성을 하였다. 

@JsonIgnoreProperties(ignoreUnknown = true)
public record AppleTokenResponse(

        @JsonProperty(value = "access_token")
        @Schema(description = "애플 access_token") String accessToken,

        @JsonProperty(value = "expires_in")
        @Schema(description = "애플 토큰 만료 기한 expires_in") String expiresIn,

        @JsonProperty(value = "id_token")
        @Schema(description = "애플 id_token") String idToken,

        @JsonProperty(value = "refresh_token")
        @Schema(description = "애플 refresh_token") String refreshToken,

        @JsonProperty(value = "token_type")
        @Schema(description = "애플 token_type") String tokenType,

        @Schema(description = "error") String error
) {
}

 

그러면 여기서 전달 받은 id_token 을 decode 하여 sub 라는 고유 id 를 획득할 수 있고 이 값으로 이후 비즈니스 로직을 처리하면 된다.

그러면 로그인 완료가 된다!

 

탈퇴 revoke

추가로 탈퇴 로직도 위 로그인 로직과 거의 유사한데, authorizationCode 를 전달 받아서 로그인 처리를 하듯 token 요청까지는 진행을 한 다음에 위에서 AppleTokenResponse 를 전달 받으면 여기서 access_token 혹은 refresh_token 을 활용하여 요청을 보내 연결 끊기를 진행할 수 있다. 

여기서 애플에서 전달해주는 refresh_token 의 경우 만료기한이 따로 없어서 이 refresh_token 을 저장해두었다가 활용해도 좋을 것 같다.

public void revoke(String accessToken) {
    try {
        AppleRevokeRequest appleRevokeRequest = AppleRevokeRequest.builder()
                .client_id(clientId)
                .client_secret(this.createSecret())
                .token(accessToken)
                .token_type_hint("access_token")
                .build();
        appleClient.revoke(appleRevokeRequest);
    } catch (HttpClientErrorException e) {
        throw new RuntimeException("Apple Revoke Error");
    }
}

 

 

참고

https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api

https://d0lim.com/blog/2022/06/login-with-apple-workaround/

https://velog.io/@givepro91/jjo2cyus

https://whitepaek.tistory.com/60