본문 바로가기

개발

[java] Spring 애플 로그인 구현 과정

애플 로그인은 구글이나 국내 소셜 로그인과는 다른 방식으로 구현해야 한다.

 

관련 포스트를 참고해 구현했지만, 일부 포스트는 필요 이상으로 의존성을 사용했고 무엇보다도 내 방식으로 정리를 해놔야 나중에 애플 로그인이 기억나지 않을 때 빠르게 이해하려고 작성했다.

 

swift와 Java Spring Boot를 사용했고, 내 역할은 백엔드였기 때문에 swift 코드는 올리지 않는다.

애플 소셜 로그인 구조

다른 로그인이 인증 서버에서 받은 토큰을 가지고 리소스 서버에 요청하면 사용자가 제공하기로 한 정보를 얻는다.

 

애플 로그인의 경우 id_token 이란 jwt 토큰을 주고 백엔드에서 이 토큰이 애플에서 만든 토큰인지 검증하고, Claim을 추출해 사용자 정보를 가져온다.

 

1, 2 번은 IOS에서 담당할 부분이고 4, 5 번이 백엔드가 담당할 부분이다.

의존성 관리

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

    implementation 'io.jsonwebtoken:jjwt-api:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3'
    runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3'
}

jwt 처리에 관련된 의존성인 3개의 jjwt를 사용한다.

 

openfeign 의 경우, 애플 인증 서버에 JSON Web Key(JWK)를 가져올 때 사용한다.

스프링에서 공식적으로 지원하는 의존성이기도 하고, docs 확인 결과 현재 서비스에선 사이드 이펙트가 발생하지 않을 거란 결론을 내렸다.

 

만약 외부 의존성을 더 추가하기 곤란하다면 자바 net에서 제공하는 HttpURLConnection을 사용해도 좋다.

openfeign 설정

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;

@EnableJpaAuditing
@EnableFeignClients
@SpringBootApplication
public class Application {

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

id_token 헤더 추출 과정

1. JWK 리스트 조회

헤더 부분을 디코딩 해, 일치하는 JWK를 찾을 alg과 kid 값을 얻는다.

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import java.security.PublicKey;
import java.util.Map;

import io.jsonwebtoken.UnsupportedJwtException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.Base64Utils;

@RequiredArgsConstructor
@Component
public class AppleTokenParser {

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

    private final ObjectMapper objectMapper;

    public Map<String, String> parseHeader(final String appleToken) {
        try {
            final String encodedHeader = appleToken.split(IDENTITY_TOKEN_VALUE_DELIMITER)[HEADER_INDEX];
            final String decodedHeader = new String(Base64Utils.decodeFromUrlSafeString(encodedHeader));
            return objectMapper.readValue(decodedHeader, Map.class);
        } catch (JsonMappingException e) {
            throw new RuntimeException("appleToken 값이 jwt 형식인지, 값이 정상적인지 확인해주세요.");
        } catch (JsonProcessingException e) {
            throw new RuntimeException("디코드된 헤더를 Map 형태로 분류할 수 없습니다. 헤더를 확인해주세요.");
        }
    }

    public Claims extractClaims(final String appleToken, final PublicKey publicKey) {
        // 이후에 설명
    }
}

2. id_token 검증

애플 서버에 JWK 리스트를 받아온다. 이를 일급 컬렉션 형태로 관리한다.

여기서 사용되는 값은 kty, n, e 3가지이고

n, e 값은 RSA의 공개키 생성에 사용, kty는 RSA란 값으로 고정돼있다.

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.Getter;

@Getter
public class ApplePublicKey {

    private final String kty;

    private final String kid;

    private final String use;

    private final String alg;

    private final String n;

    private final String e;

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

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

    @JsonCreator
    public ApplePublicKey(@JsonProperty("kty") final String kty,
                          @JsonProperty("kid") final String kid,
                          @JsonProperty("use") final String use,
                          @JsonProperty("alg") final String alg,
                          @JsonProperty("n") final String n,
                          @JsonProperty("e") final String e) {
        this.kty = kty;
        this.kid = kid;
        this.use = use;
        this.alg = alg;
        this.n = n;
        this.e = e;
    }
}

JWK 리스트를 받아오면 이 안에, id_token과 일치하는 kid와 alg 값이 존재한다.

import java.util.List;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class ApplePublicKeys {

    private List<ApplePublicKey> keys;

    public ApplePublicKeys(List<ApplePublicKey> keys) {
        this.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 RuntimeException("잘못된 토큰 형태입니다."));
    }
}

JWK 리스트를 받아오는 코드는 다음과 같다.

import org.springframework.cloud.openfeign.FeignClient;
import org.springframework.web.bind.annotation.GetMapping;

@FeignClient(name = "apple-public-key", url = "https://appleid.apple.com")
public interface AppleClient {

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

3. RSA 공개키 생성

n, e 를 사용해 RSA 공개키를 생성한다. 생성에 성공했다면, id_token 의 Claim 추출에 사용된다.

import org.springframework.stereotype.Component;

import java.math.BigInteger;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.RSAPublicKeySpec;
import java.util.Base64;
import java.util.Map;

@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(final Map<String, String> headers, final ApplePublicKeys publicKeys) {
        final ApplePublicKey applePublicKey = publicKeys.getMatchingKey(
            headers.get(SIGN_ALGORITHM_HEADER),
            headers.get(KEY_ID_HEADER)
        );
        return generatePublicKey(applePublicKey);
    }

    private PublicKey generatePublicKey(final ApplePublicKey applePublicKey) {
        final byte[] nBytes = Base64.getUrlDecoder().decode(applePublicKey.getN());
        final byte[] eBytes = Base64.getUrlDecoder().decode(applePublicKey.getE());

        final BigInteger n = new BigInteger(POSITIVE_SIGN_NUMBER, nBytes);
        final BigInteger e = new BigInteger(POSITIVE_SIGN_NUMBER, eBytes);
        final RSAPublicKeySpec rsaPublicKeySpec = new RSAPublicKeySpec(n, e);

        try {
            final KeyFactory keyFactory = KeyFactory.getInstance(applePublicKey.getKty());
            return keyFactory.generatePublic(rsaPublicKeySpec);
        } catch (NoSuchAlgorithmException | InvalidKeySpecException exception) {
            throw new RuntimeException("잘못된 애플 키");
        }
    }
}

4. id_token Claim 추출

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonMappingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import java.security.PublicKey;
import java.util.Map;

import io.jsonwebtoken.UnsupportedJwtException;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.util.Base64Utils;

@Slf4j
@RequiredArgsConstructor
@Component
public class AppleTokenParser {

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

    private final ObjectMapper objectMapper;

    public Map<String, String> parseHeader(final String appleToken) {
        // 아까 있었던 코드
    }

    public Claims extractClaims(final String appleToken, final PublicKey publicKey) {
        try {
            return Jwts.parser()
                    .verifyWith(publicKey)
                    .build()
                    .parseSignedClaims(appleToken)
                    .getPayload();
        } catch (UnsupportedJwtException e) {
            throw new UnsupportedJwtException("지원되지 않는 jwt 타입");
        } catch (IllegalArgumentException e) {
            throw new IllegalArgumentException("비어있는 jwt");
        } catch (JwtException e) {
            throw new JwtException("jwt 검증 or 분석 오류");
        }
    }
}

이 로직을 실행하는 코드는 다음과 같다.

AppleUser는 임의로 만든 객체이기 때문에, 추출할 정보들을 가지고 따로 만들면 된다.

import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.security.PublicKey;
import java.util.Map;

@RequiredArgsConstructor
@Component
public class AppleOauthService {

    private final AppleTokenParser appleTokenParser;
    private final AppleClient appleClient;
    private final ApplePublicKeyGenerator applePublicKeyGenerator;

    private final String DEFAULT_NAME = "apple";
    private final String CLAIM_EMAIL = "email";

    public AppleUser createAppleUser(final String appleToken) {
        final Map<String, String> appleTokenHeader = appleTokenParser.parseHeader(appleToken);
        final ApplePublicKeys applePublicKeys = appleClient.getApplePublicKeys();
        final PublicKey publicKey = applePublicKeyGenerator.generate(appleTokenHeader, applePublicKeys);
        final Claims claims = appleTokenParser.extractClaims(appleToken, publicKey);
        return new AppleUser(DEFAULT_NAME, claims.get(CLAIM_EMAIL, String.class));
    }
}

우리 서비스에선 이메일을 식별자로 사용했기 때문에, 이메일 값만 추출했다.

테스트 코드

app에서 로그인을 해보며 디버깅하는 수고를 줄일 수 있다.

id_token 파싱 테스트

더보기
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import org.junit.jupiter.api.Test;

import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.util.Date;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;

@SuppressWarnings("NonAsciiCharacters")
public class AppleTokenParserTest {

    private final AppleTokenParser appleTokenParser = new AppleTokenParser(new ObjectMapper());

    @Test
    public void 애플_토큰_헤더_파싱_테스트() throws NoSuchAlgorithmException {
        // given
        Date now = new Date();
        KeyPair keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair();
        PrivateKey privateKey = keyPair.getPrivate();
        String appleToken = Jwts.builder()
                .header().add("kid", "kid 값")
                .and()
                .claim("email", "test@test.com")
                .issuer("https://appleid.apple.com")
                .issuedAt(now)
                .audience().add("aud 값")
                .and()
                .subject("sub_test")
                .expiration(new Date(now.getTime() + 60 * 60 * 1000))
                .signWith(privateKey, Jwts.SIG.RS256)
                .compact();

        // when
        Map<String, String> headers = appleTokenParser.parseHeader(appleToken);

        // given
        assertThat(headers).containsKeys("alg", "kid");
    }

    @Test
    public void 애플_클레임_파싱_테스트() throws NoSuchAlgorithmException {
        // given
        Date now = new Date();
        KeyPair keyPair = KeyPairGenerator.getInstance("RSA")
                .generateKeyPair();
        PrivateKey privateKey = keyPair.getPrivate();
        PublicKey publicKey = keyPair.getPublic();
        String appleToken = Jwts.builder()
                .header().add("kid", "kid 값")
                .and()
                .claim("email", "test@test.com")
                .issuer("https://appleid.apple.com")
                .issuedAt(now)
                .audience().add("aud 값")
                .and()
                .subject("sub_test")
                .expiration(new Date(now.getTime() + 60 * 60 * 1000))
                .signWith(privateKey, Jwts.SIG.RS256)
                .compact();

        // when
        Claims claims = appleTokenParser.extractClaims(appleToken, publicKey);

        // then
        assertThat(claims.get("email")).isEqualTo("test@test.com");
    }
}

JWK 검증 테스트

더보기
import org.junit.jupiter.api.Test;

import java.security.PublicKey;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.*;

@SuppressWarnings("NonAsciiCharacters")
class ApplePublicKeyGeneratorTest {

    private final ApplePublicKeyGenerator applePublicKeyGenerator = new ApplePublicKeyGenerator();

    @Test
    public void 헤더와_일치하는_키로_퍼블릭_키_생성() {
        // given
        Map<String, String> headers = new HashMap<>();
        headers.put("kid", "W6WcOKB");
        headers.put("alg", "RS256");

        List<ApplePublicKey> keys = new ArrayList<>();
        setKeys(keys);
        ApplePublicKeys applePublicKeys = new ApplePublicKeys(keys);

        // when
        PublicKey publicKey = applePublicKeyGenerator.generate(headers, applePublicKeys);

        // then
        assertThat(publicKey.getAlgorithm()).isEqualTo("RSA");
    }

    private void setKeys(List<ApplePublicKey> keys) {
        keys.add(new ApplePublicKey("RSA",
            "W6WcOKB",
            "sig",
            "RS256",
            "2Zc5d0-zkZ5AKmtYTvxHc3vRc41YfbklflxG9SWsg5qXUxvfgpktGAcxXLFAd9Uglzow9ezvmTGce5d3DhAYKwHAEPT9hbaMDj7DfmEwuNO8UahfnBkBXsCoUaL3QITF5_DAPsZroTqs7tkQQZ7qPkQXCSu2aosgOJmaoKQgwcOdjD0D49ne2B_dkxBcNCcJT9pTSWJ8NfGycjWAQsvC8CGstH8oKwhC5raDcc2IGXMOQC7Qr75d6J5Q24CePHj_JD7zjbwYy9KNH8wyr829eO_G4OEUW50FAN6HKtvjhJIguMl_1BLZ93z2KJyxExiNTZBUBQbbgCNBfzTv7JrxMw",
            "AQAB"
            ));

        keys.add(new ApplePublicKey("RSA",
            "fh6Bs8C",
            "sig",
            "RS256",
            "u704gotMSZc6CSSVNCZ1d0S9dZKwO2BVzfdTKYz8wSNm7R_KIufOQf3ru7Pph1FjW6gQ8zgvhnv4IebkGWsZJlodduTC7c0sRb5PZpEyM6PtO8FPHowaracJJsK1f6_rSLstLdWbSDXeSq7vBvDu3Q31RaoV_0YlEzQwPsbCvD45oVy5Vo5oBePUm4cqi6T3cZ-10gr9QJCVwvx7KiQsttp0kUkHM94PlxbG_HAWlEZjvAlxfEDc-_xZQwC6fVjfazs3j1b2DZWsGmBRdx1snO75nM7hpyRRQB4jVejW9TuZDtPtsNadXTr9I5NjxPdIYMORj9XKEh44Z73yfv0gtw",
            "AQAB"
        ));

        keys.add(new ApplePublicKey("RSA",
            "YuyXoY",
            "sig",
            "RS256",
            "1JiU4l3YCeT4o0gVmxGTEK1IXR-Ghdg5Bzka12tzmtdCxU00ChH66aV-4HRBjF1t95IsaeHeDFRgmF0lJbTDTqa6_VZo2hc0zTiUAsGLacN6slePvDcR1IMucQGtPP5tGhIbU-HKabsKOFdD4VQ5PCXifjpN9R-1qOR571BxCAl4u1kUUIePAAJcBcqGRFSI_I1j_jbN3gflK_8ZNmgnPrXA0kZXzj1I7ZHgekGbZoxmDrzYm2zmja1MsE5A_JX7itBYnlR41LOtvLRCNtw7K3EFlbfB6hkPL-Swk5XNGbWZdTROmaTNzJhV-lWT0gGm6V1qWAK2qOZoIDa_3Ud0Gw",
            "AQAB"
        ));
    }

}

 

문서엔 있었지만 안 해도 됐던 것

애플 문서를 보면 p8 값과, clientId, teamId 등을 가지고 개인 키를 만들어 애플 인증 서버로부터 access, refresh 토큰을 받아야 했다.

 

플로우 설명

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

 

프라이빗 토큰 구성

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

애플 인증 서버에서 제공하는 access 토큰은 5분으로 매우 짧았기 때문에, 앱을 사용하면 5분마다 재인증을 해야 하는 문제가 있었다.

 

또한, refresh 토큰은 첫 response 후 다시 제공하지 않은 문제가 있었다.

refresh 토큰이 노출되면 해당 사용자를 탈퇴시키고 재가입해야 하는 극단적인 방법이었고, 우리 서비스에선 refresh 토큰도 일정 주기마다 새로 발급하도록 만들었다.

 

그래서 자체적으로 인증 토큰을 만들어 관리했고, 애플 로그인 구현 시 해당 부분까진 구현하지 않았다.

Reference

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

 

Authenticating users with Sign in with Apple | Apple Developer Documentation

Securely authenticate users and create accounts for them in your app.

developer.apple.com

 

https://peterica.tistory.com/85

 

[Spring] Spring에서 APPLE로그인 구현하기

서론 개발 프로젝트에서 Apple로그인을 적용하였습니다. 프론트 개발자와 협업하여 Apple로그인을 구현하였고, 이 포스팅은 백엔드의 입장에서 정리를 해 보았습니다. 앱에서 APPLE로그인 성공 후

peterica.tistory.com

https://hwannny.tistory.com/71

 

Spring API서버에서 Apple 인증(로그인 , 회원가입) 처리하기

들어가며 사이드 프로젝트를 진행하던 도중 APP에서 Apple 로그인을 적용해야 했다. https://apps.apple.com/kr/app/%EA%B8%80%EC%9D%84%EB%8B%B4%EB%8B%A4/id1517289762 ‎글을담다 ‎마음 속 와 닿은 글을 손쉽게 담는, '

hwannny.tistory.com

 

'개발' 카테고리의 다른 글

서버에서 rm 실수를 줄일만한 방법  (0) 2024.02.28
redis 2.0 분석  (0) 2023.12.20
redis 1.0 분석 - 공유 메모리 풀  (0) 2023.11.30
초기 Redis 분석 - 이벤트 루프  (0) 2023.11.24
초기 Redis 분석 - 자료구조  (0) 2023.11.22