[Java][Spring] 애플 OAuth 로그인 구현

SUI·2024년 5월 18일

Java

목록 보기
3/4
post-thumbnail

제3자 인증 로그인을 사용하는 앱의 경우, 앱스토어에 올리기 위해서는 무조건 애플 로그인이 구현되어 있어야 한다...🤦
현재 우리 프로젝트는 카카오 로그인만으로 회원가입이 가능하기 때문에 리젝 당하지 않기 위해서는 애플 로그인을 만들어야 한다. 지금부터 애플 OAuth 로그인을 구현해 보자!

애플 로그인은 생각보다 악명이 높은 편이다. 우리나라에는 카카오 로그인이라는 꽤 편리한 OAuth 서비스가 있는데, 애플 로그인은 카카오 로그인에 비해 개발자에게 요구하는 게 많고 문서도 불친절하다 보니 상대적으로 비교돼서 더 악명이 높아진 것 같다. (영어로 되어있다는 점을 차치하고서도 불친절하다🥲)

공식 문서
Apple Developers OAuth REST API

애플 홈페이지에 있는 다이어그램만으로는 이해하기가 힘들어서 우리 프로젝트에 맞춰 새로 시퀀스 다이어그램을 만들어봤다. 이렇게 보니까 꽤 쉬워보인다. 직접 할 때는 안그랬는데...🙄 어쨌든 위 시퀀스 다이어그램에 맞춰 차근차근 정리해보자.

처음 애플 로그인을 봤을 때 헷갈렸던 부분은 '어떤 정보를 클라이언트에서 받는가' 였다. 크게 2가지 경우가 있는데, 인가코드를 전달받을 수도 있고 id_token을 전달 받을 수도 있다. id_token을 받는다면 client-secret으로 apple server에 id_token을 요청하는 작업을 프론트엔드에서 구현해야한다.
client-secret을 만들때는 .p8 키 파일 등 민감한 정보가 포함되기 때문에 백엔드에서 구현하기로 했다.

그리고 본격적으로 기능 구현을 하기 전에 Apple Developer에서 설정을 해줘야한다. 이 부분은 아주 친절하게 설명해주신 분들이 많기 때문에 링크로 대체한다😄

참고 자료 🙇
https://whitepaek.tistory.com/60
https://imweb.me/faq?mode=view&category=29&category2=47&idx=71719


1️⃣ id_token 받기

간단하게 id_token이라고 썼지만 정확하게 얘기하면 id_token, access_token, refresh_token, expires_in, token_type 이렇게 5가지 data를 받는다. 여기서 우리가 쓸 정보는 사실상 id_token 밖에 없다. id_token에는 사용자 정보(사용자 고유 식별자, 이름, 이메일)가 들어있으며 JWT Token 형식이다.

{
    "access_token": "access_token",
    "id_token": "id_token",
    "refresh_token": "refresh_token",
    "expires_in": 3600,
    "token_type": "Bearer"
}

🤔 access_token이랑 refresh_token은 왜 안 쓸까?

개인적으로도 초반에 이걸 써야 하는 건가 싶어서 고민을 많이 했지만 쓸 이유가 없었다.
왜냐하면 애플의 access_token의 만료 기간은 10분이고, refresh_token은 무려 만료 기간도 없고 한 번 받으면 재발급도 못받는다! 🤷 불편하게 애플 서버를 왔다 갔다 하면서 이 토큰을 써야 하는 이유를 모르겠기에 애플에서 제공하는 토큰 대신, 카카오 로그인과 마찬가지로 우리 서버에서 발급하는 토큰을 사용하기로 했다.


📑 1-1. 인가코드를 받고 client-secret 생성

클라이언트(iOS App)에서 애플 로그인을 하게 되면 인가코드를 전달받는다. 서버는 클라이언트에서 인가코드를 전달받아 client-secret을 만들어야한다.
client-secret을 만들 때는 Apple Developer에서 설정한 정보들과 발급받은 .p8파일이 필요하다. 모두 민감한 정보지만 특히 .p8 파일은 한 번만 발급받을 수 있고 굉장히 민감한 정보이기 때문에 각별하게 관리해줘야한다.

  • alg : 사용하는 알고리즘 = ES256
  • kid : 키 식별자 = Apple Developers에 설정된 Team ID
  • iss : 토큰 발급자 = Apple Developers에 설정된 Team ID
  • iat : 토큰 발급 시간 = 현재 시간
  • exp : 토큰 만료 시간(6개월 이하여야 함)
  • aud : 토큰 사용 대상자 = "https://appleid.apple.com"
  • sub : 토큰의 주체 = App의 Bundle ID

OAuth 프로세스에서는 client-idApp의 Bundle ID를 사용하기 때문에 둘은 같은 값이라고 생각하면 된다. 앞으로 사용될 모든 client-id는 Bundle ID를 뜻한다.

appleKeyPath는 .p8파일이 저장되어있는 경로이다. .p8파일로 PrivateKey를 만들고 JWT Token에 서명한 뒤 String 값으로 반환했다.

@Component
public class AppleClientSecret {

    @Value("${apple.key-path}")
    private String appleKeyPath;

    @Value("${apple.client-id}")
    private String clientId;

    @Value("${apple.team-id}")
    private String teamId;
    
    @Value("${apple.key-id}")
    private String keyId;

    @Value("${apple.aud}")
    private String aud;

    public String createClientSecret() throws IOException {
        Date expirationDate = Date.from(LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant());
        Map<String, Object> jwtHeader = new HashMap<>();
        jwtHeader.put("alg", "ES256");
        jwtHeader.put("kid", keyId);

        return Jwts.builder()
                .setHeaderParams(jwtHeader)
                .setIssuer(teamId)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(expirationDate)
                .setAudience(aud)
                .setSubject(clientId)
                .signWith(getPrivateKey(), SignatureAlgorithm.ES256)
                .compact();
    }

    private PrivateKey getPrivateKey() throws IOException {
        ClassPathResource resource = new ClassPathResource(appleKeyPath);
        String privateKey = new String(Files.readAllBytes(Paths.get(resource.getURI())));
        Reader pemReader = new StringReader(privateKey);
        PEMParser pemParser = new PEMParser(pemReader);
        JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
        PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject();
        return converter.getPrivateKey(object);
    }

}

📑 1-2. 인가코드와 client-secret으로 id_token 요청

인가코드와 방금 만든 client-secret를 이용해서 애플 서버에 id_token을 달라는 요청을 보낸다. 이때 요청은 FeignClient를 사용했다. FeignClient는 넷플릭스에서 만든 Http Client로, RestTemplate과 비슷하지만 긴 Configure를 구성할 필요없이 인터페이스만 구현하여 요청을 보낼 수 있다는 장점이 있다.

먼저 의존성을 추가해준다.

implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'

그 다음 Application에 @EnableFeignClients 어노테이션을 추가한다.

@EnableFeignClients
@SpringBootApplication
public class Application {

    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }

}

Apple Server에 id_token을 요청하는 인터페이스를 작성한다.

  • code : 클라이언트에서 전달받은 인가코드
  • grant_type : OAuth 2.0 프로토콜에서 클라이언트가 인증 서버에 어떤 방식으로 접근 토큰을 요청하는지 지정하는 매개변수이다. 여러가지 매개변수가 있지만, 여기서는 사용자 인증 및 권한 부여를 위해 사용하는 "authorization_code"를 문자열로 넣어준다.
  • client_id : App의 Bundle ID
  • client_secret : 이전에서 만든 clientSecret
@Component
@FeignClient(name = "apple-oauth", url = "https://appleid.apple.com")
public interface AppleClient {

    @PostMapping("/auth/token")
    AppleTokenResponse appleAuth(
            @RequestParam("client_id") String clientId,
            @RequestParam("code") String code,
            @RequestParam("grant_type") String grantType,
            @RequestParam("client_secret") String clientSecret);

}
public record AppleTokenResponse(
        @JsonProperty("access_token") String accessToken,
        @JsonProperty("id_token") String idToken,
        @JsonProperty("refresh_token") String refreshToken,
        @JsonProperty("expires_in") Long expiresIn,
        @JsonProperty("token_type") String tokenType
) {
}

응답값을 받는 객체인 AppleTokenResponse의 필드에 @JsonProperty 어노테이션을 사용하여 응답으로 오는 Json값을 해당하는 필드에 매핑시켜줬다. 이제 요청을 보내면 id_token을 비롯한 data들을 받을 수 있다.


2️⃣ Public-Key로 RSAPublicKey 만들기

이제 id_token에서 Claims를 추출하고 검증하면 끝!이지만 거기까지 가기위한 과정이 좀 복잡하다.
Claims를 추출하기 위해선 애플 서버에서 public-key list(여러 개의 public-key가 들어있는 list)를 받고, 이 list 안에서 이전에 받은 id_token의 Header 정보(kid, alg)가 일치하는 하나의 public-key를 이용해서 RSAPublicKey를 만들어야 한다.


📑 2-1. id_token에서 Header 정보 파싱하기

아까 받은 id_token의 Header에 있는 kid, alg 값을 구해야한다.

id_token은 JWT Token 형식이며, JWT Token의 .을 기준으로 Header, Payload, Signature로 나뉘어있다. 우리가 원하는 건 Header이므로, id_token을 .으로 나눈 뒤 index 0번째 값을 가져오면 id_token의 Header를 가져올 수 있다.

그리고 JWT Token은 Base 64로 인코딩 되어있기 때문에 다시 Base 64로 디코딩해 주고 Map<String,String>에 저장하면 최종적으로 아래와 같은 정보를 확인할 수 있다.

{
  "kid": "AIDF96054",
  "alg": "ES256"
}
@Component
@RequiredArgsConstructor
public class AppleTokenParser {

    private static final String ID_TOKEN_SEPARATOR = "\\.";
    private static final int HEADER_INDEX = 0;

    private final ObjectMapper objectMapper;

    public Map<String, String> parseHeader(String idToken) {
        try {
            final String encodedHeader = idToken.split(ID_TOKEN_SEPARATOR)[HEADER_INDEX];
            final String decodedHeader = Arrays.toString(Base64.getUrlDecoder().decode(encodedHeader));

            return objectMapper.readValue(decodedHeader, Map.class);

        } catch (JsonMappingException e) {
            throw new InvalidTokenException("Parsing Header Error : 토큰의 헤더를 매핑하는 데 실패했습니다");
        } catch (JsonProcessingException e) {
            throw new InvalidTokenException("Parsing Header Error : 토큰 처리 중 오류가 발생했습니다");
        } catch (ArrayIndexOutOfBoundsException e) {
            throw new InvalidTokenException("Parsing Header Error : 유효하지 않은 토큰 형식입니다.");
        }
    }

📑 2-2. public-key list 받기

애플 서버에 요청해서 public-key list를 받는다. 위에서 작성했던 AppleClient 클래스 안에 아래 코드만 추가하면 된다! 그냥 웹 브라우저에서 해당 URL(https://appleid.apple.com/auth/keys)로 접근만 해도 아래와 같이 응답 값이 오는 걸 확인할 수 있으니 궁금하면 해보자.

    @GetMapping("/auth/keys")
    ApplePublicKeys getApplePublicKeys();


📑 2-3. 유효한 public-key로 RSAPublicKey 만들기

2-1의 과정에서 추출한 kid, alg 값을 public-key list에 있는 kid, alg 값과 비교해서 하나의 유효한 public-key를 찾고, RSAPublicKey를 만든다.

ApplePublicKey를 만들어서 Apple Server에서 받은 public-key list의 각 public-key가 가지고 있는 data를 저장하도록 했다. public-key들을 List로 받을 수 있는 ApplePublicKeys도 만들어준다. ApplePublicKeysgetMatchingKey 메서드는 밑에서 public-key list를 순회하면서 일치하는 public-key를 찾는 메서드이다.

id_token과 일치하는지 확인할 때 kid, alg를 사용하고, RSAPublicKey를 만들 때 kty, n, e를 사용한다. use는 키의 용도를 설명하는 메타데이터라서 여기서 쓸 일은 없다.

public record ApplePublicKeys(
        List<ApplePublicKey> keys
        
) {
    public ApplePublicKeys {
        keys = List.copyOf(keys);
    }

    public ApplePublicKey getMatchingKey(final String alg, final String kid) {
        return keys.stream()
                .filter(key -> key.isSameAlg(alg) && key.isSameKid(kid))
                .findFirst()
                .orElseThrow(() -> new AppleOauthException("public-key 형태가 잘못되었습니다."));
    }
}
public record ApplePublicKey(
        @JsonProperty("kty") String kty,
        @JsonProperty("kid") String kid,
        @JsonProperty("use") String use,
        @JsonProperty("alg") String alg,
        @JsonProperty("n") String n,
        @JsonProperty("e") String e
) {

    public boolean isSameAlg(String alg) {
        return this.alg.equals(alg);
    }

    public boolean isSameKid(String kid) {
        return this.kid.equals(kid);
    }
}

일치하는 public-key를 찾고, 해당 public-key의 kty, n, e를 사용해서 RSAPublicKey를 만들고 반환한다.

POSITIVE_SIGN_NUMBER는 양수 BigInteger를 생성하기 위한 값이다.
BigInteger(int bitLength, byte[] val) 생성자는 주어진 비트 길이의 BigInteger를 생성하는데, bitLength는 생성될 BigInteger의 비트 길이를 나타내며, val은 BigInteger의 값을 나타낸다. 이때 val 배열의 가장 왼쪽 비트가 1인 경우, 생성된 BigInteger는 양수가 된다.

generatePublicKey 메서드는 주어진 ApplePublicKey 객체를 사용하여 RSAPublicKey를 생성하고 반환하는 메서드이다.
먼저, public-key에서 ne 값을 Base 64 디코딩하여 byte 배열로 변환하고, BigInteger를 사용하여 byte 배열을 BigInteger로 변환한다. 그런 다음 RSAPublicKeySpec을 사용하여 ne 값을 포함하는 RSAPublicKeySpec 객체를 생성한다. 마지막으로 KeyFactory를 사용하여 RSAPublicKey를 생성하고 반환한다.

@Component
public class ApplePublicKeyGenerator {

    private static final String SIGN_ALGORITHM_HEADER = "alg";
    private static final String KEY_ID_HEADER = "kid";
    private static final int POSITIVE_SIGN_NUMBER = 1;

    public PublicKey generate(Map<String, String> headers, ApplePublicKeys publicKeys) {
        // id_token에서 추출한 alg, kid와 일치하는 alg, kid를 가진 publicKey
        ApplePublicKey applePublicKey = publicKeys.getMatchingKey(
                headers.get(SIGN_ALGORITHM_HEADER),
                headers.get(KEY_ID_HEADER)
        );
        return generatePublicKey(applePublicKey);
    }

    // publicKey를 통해 RSAPublicKey 생성 & JWS E256 Signature 검증
    private PublicKey generatePublicKey(ApplePublicKey applePublicKey) {
        byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.n());
        byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.e());

        // publicKey의 n, e 값으로 RSAPublicKeySpec 생성
        BigInteger n = new BigInteger(POSITIVE_SIGN_NUMBER, nBytes);
        BigInteger e = new BigInteger(POSITIVE_SIGN_NUMBER, eBytes);
        RSAPublicKeySpec rsaPublicKeySpec = new RSAPublicKeySpec(n, e);

        try {
            // publicKey의 kty 값으로 KeyFactory 생성
            KeyFactory keyFactory = KeyFactory.getInstance(applePublicKey.kty());
            // 생성한 KeyFactory와 PublicKeySpec으로 RSAPublicKey 생성
            return keyFactory.generatePublic(rsaPublicKeySpec);
        } catch (NoSuchAlgorithmException | InvalidKeySpecException exception) {
            throw new AppleOauthException("RSAPublicKey생성 중 오류가 발생했습니다.");
        }
    }
}

3️⃣ Claims 추출과 id_token 검증

이제 id_token의 Claims를 추출하면 된다! 되긴 되는데... Claims를 추출하면서 한가지를 더 해줘야한다...🥹 애플과 친구가 되기위해서는 해달라는건 다 해줘야하니까...

아래 5가지 검증 과정을 반드시 거쳐야한다.

  • Verify the JWS E256 signature using the server’s public key
    : 서버의 PublicKey를 사용하여 JWS E256 서명을 검증합니다.
  • Verify the nonce for the authentication
    : 인증을 위한 nonce 값을 검증합니다.
  • Verify that the iss field contains https://appleid.apple.com
    : iss 필드가 https://appleid.apple.com을 포함하는지 검증합니다.
  • Verify that the aud field is the developer’s client_id
    : aud 필드가 개발자의 client_id와 일치하는지 검증합니다.
  • Verify that the time is earlier than the exp value of the token
    : 현재 시간이 토큰의 exp 값보다 이전인지 검증합니다.

여기서 첫번째 과정에서 말하는 "서버의 PublicKey"가 바로 이전에 만든 RSAPublicKey이다.

nonce가 뭔지 몰라서 좀 헷갈렸는데, nonce는 클라이언트에서 생성하는 임의의 일회용 무작위 문자열이다. 처음 클라이언트에서 Apple Server에 인가코드를 요청할 때 이 nonce를 함께 보낸다. Apple Server는 전달받은 nonce를 id_token의 Payload에 포함시킨다. 클라이언트가 Apple Server에서 인가코드를 전달받은 뒤, 서버에 인가코드와 nonce를 함께 전달한다. 서버는 id_token의 Claims를 추출하고 nonce가 일치하는지 확인한다.


📑 3-1. RSAPublicKey로 Claims 추출

아래 코드의 publicKey는 이전에 생성한 RSAPublicKey이다. 보통 JWT 토큰은 서명되어 있으며, 이 서명은 토큰이 변조되지 않았음을 보장한다. 서명을 확인하려면 해당 서명에 사용된 PublicKey가 필요하고, setSigningKey() 메서드는 이러한 공개 키를 JWT Parser에 제공하여 서명을 확인할 수 있게 해준다. 여기에 RSAPublicKey를 넣어주면 된다. 검증 과정에서 오류가 발생하면 예외를 던지도록 설정했다.

    // claims를 추출하여 사용자 정보 가져오기
    public Claims extractClaims(String idToken, PublicKey publicKey) {
        try {
            return Jwts.parserBuilder()
                    .setSigningKey(publicKey)
                    .build()
                    .parseClaimsJws(idToken)
                    .getBody();
        } catch (UnsupportedJwtException e) {
            throw new UnsupportedJwtException("Extract Claims Error : 지원되지 않는 JWT 형식입니다.");
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("Extract Claims Error : 유효하지 않은 값입니다.");
        } catch (JwtException e) {
            throw new JwtException("Extract Claims Error : JWT 처리 중 오류가 발생했습니다.");
        }
    }

📑 3-2. id_token 검증 : iss, aud, nonce, exp

위 과정에서 JWS E256 signature 검증은 끝났다. 이제 iss, aud, nonce, exp를 모두 검증하는 Validator를 만들었다.
만약 isValid의 값이 false이면 검증에 실패한 것이므로 예외를 던지도록 하면 된다. true면 추출한 Claims의 data를 사용하면 된다!

@Component
public class AppleClaimsValidator {

    private final String iss;
    private final String clientId;

    public AppleClaimsValidator(
            @Value("${apple.iss}") String iss,
            @Value("${apple.client-id}") String clientId
    ) {
        this.iss = iss;
        this.clientId = clientId;
    }

    public boolean isValid(Claims claims, String nonce) {
        // exp, nonce, iss, aud 검증
        Date expiration = claims.getExpiration();
        Date currentDate = new Date();

        return expiration.before(currentDate) &&
                claims.getIssuer().contains(iss) &&
                claims.getAudience().equals(clientId) &&
                claims.get(NONCE_KEY, String.class).equals(nonce);
    }
}

아래는 id_token에서 추출한 Claims data의 예시이다.
emailname은 최초에 클라이언트에서 Apple Server에 인가코드를 요청할 때 scope 설정을 해줘야 얻을 수 있다. 설정을 해주면 사용자가 애플 로그인을 할 때 애플리케이션이 요청한 정보(email, name)을 제공하는 것에 동의해야 받을 수 있다.
subApple에서 제공하는 사용자 고유 식별자이고 UUID 형태로 제공된다.

{
  "iss": "https://appleid.apple.com",
  "sub": "unique_user_id",
  "aud": "client_id_of_the_client_application",
  "exp": 1632480000,
  "iat": 1632476400,
  "nonce": "randomly_generated_nonce_value",
  "email": "user@example.com",
  "email_verified": true,
  "auth_time": 1632476400,
  "name": "User Name",
  "picture": "https://example.com/profile_picture.jpg"
}

검증 과정도 끝나고 사용자의 정보도 가져왔다! 이제 가져온 사용자 정보로 자체적인 회원가입 또는 로그인 과정을 이어나가면 된다!🥸

덧붙여서 애플 로그인을 구현했다면 애플 회원 탈퇴반드시 구현해야한다. 공식적인 내용은 아래에서 확인할 수 있다.

앱스토어 업데이트
6월 30일부터 시작되는 계정 삭제 요구 사항




🚧 FeignClient 오류 해결

현재 SpringBoot 3.x버전을 사용할 경우 configuration에 오류가 있는 듯 하다. 나의 경우는 아래와 같은 오류 메세지가 계속 뜨면서 실행이 되지않았다.

Consider defining a bean of type 'org.springframework.cloud.openfeign.FeignContext' in your configuration

같은 오류를 겪는 사람이 많아서 구글링을 하니 해결법을 금방 찾을 수 있었다. 아래는 이 오류와 관련된 스택오버플로우 글과 답변이다.

스택오버플로우
SpringBoot 3.x + FeignClient 오류와 해결법

If you are using Spring 3.0.0 the "spring.factories" has been removed. The automatic import of autoconfigurations is broken if your dependency uses this.
https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide#auto-configuration-files
The one way to fix this on your side without modifying the dependecy itself is using @ImportAutoConfiguration({ list of autoconfiguration classes }) explicitly. You can also use new functionality "imports file" provided by Spring. The right way is to migrate "spring.factories" to "imports file" on the dependency side.

✍️ SpringBoot 3.x 버전과 FeignClient

spring.factories는 Spring Boot 애플리케이션의 시작 시 자동으로 적용될 여러 구성 클래스들을 등록하는 데 사용된다. 개발자는 org.springframework.boot.autoconfigure.EnableAutoConfiguration 키 아래에 자동 구성 클래스를 나열하여 Spring Boot가 애플리케이션을 실행할 때 자동으로 클래스들을 찾아 적용하도록 할 수 있다. 그런데 Spring 3.0.0으로 업그레이드 되면서 spring.factories가 없어졌고, 그로 인해 FeignClient auto import에 오류가 생긴 것이다.

해결 방법은 간단한데, FeignClient 구성 파일에 @ImportAutoConfiguration 어노테이션으로 명시적으로 필요한 자동 구성 클래스들을 가져오도록 하면 된다.

@Configuration
@ImportAutoConfiguration({FeignAutoConfiguration.class, HttpClientConfiguration.class})
public class FeignClientConfig {
}



참고 자료 🙇
https://brorica.tistory.com/250
https://whitepaek.tistory.com/61
https://gengminy.tistory.com/56
https://kth990303.tistory.com/436
https://velog.io/@byeongju/Apple-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0
https://kedric-me.tistory.com/entry/JAVA-%EC%95%A0%ED%94%8C-%EC%97%B0%EB%8F%99%ED%95%B4%EC%A0%9C-%EA%B5%AC%ED%98%84-Sign-Out-of-Apple-ID

1개의 댓글

comment-user-thumbnail
2024년 10월 24일

사용자 로그인 이후 Apple Server로부터 authorization_code(인가코드) 받을 때, id_token도 같이 수신할 수 있지 않나요?? 그러면 client-secret 생성도 필요 없을 것 같은데 어떻게 생각하시는지 궁금합니다

답글 달기