Apple 로그인 구현하기

공병주(Chris)·2023년 2월 14일
3

2023 글로벌미디어학부 졸업 작품 프로젝트 Dandi 에서 Apple Id로 로그인하기를 구현했습니다.

여러 OAuth가 있지만, iOS로 배포할 예정이라서 Apple Login을 채택했습니다. (사실 채택이라기 보다는 iOS 앱에서 OAuth를 사용하려면 Apple Login이 적용되어 있어야 심사를 통과할 수 있습니다.)

OAuth 사용 이유

자체 회원가입 / 로그인 절차를 구성할 수도 있지만 아래와 같은 이유로 자체 로그인을 사용하지 않았습니다.

  1. 특정 집단에 있는 사용자인지 확인할 필요 없다.

  2. 사용자 경험 측면에서 OAuth를 사용하면 더 빠르게 회원 가입 및 로그인 절차를 진행할 수 있다.

  3. 자체 회원가입 / 로그인의 특성상 사용자가 아이디와 패스워드를 기억해야한다.
    이전 우테코 프로젝트에서 자체 로그인을 사용했다. 익명성 보장을 위해 비밀번호 찾기 기능을 제공하지 않았는데, 비밀번호를 잃어버렸다는 CS가 많이 들어왔다.

OAuth 2.0

위키백과에 아래와 같이 OAuth를 정의합니다.

인터넷 사용자들이 비밀번호를 제공하지 않고 다른 웹사이트 상의 자신들의 정보에 대해 웹사이트나 애플리케이션의 접근 권한을 부여할 수 있는 공통적인 수단으로서 사용되는, 접근 위임을 위한 개방형 표준이다.

OAuth에는 Resource Server, Resource Owner, Client라는 개념이 있고 이 주체들을 기반으로 OAuth가 진행됩니다. OAuth에 대한 내용은 아래 글을 읽어보시면 좋을 것 같습니다.

OAuth의 기본 개념(Tecoble)

Apple OAuth

Apple OAuth 흐름

Apple OAuth 흐름1
Apple OAuth 흐름2

애플 공식 문서에 Apple OAuth의 흐름이 나와있습니다. 하지만, 저희는 위 절차 그대로 사용하지 않고, Apple 서버에서 사용자 식별 값만 받아서 사용자 객체에 매핑 시켰습니다. 또한, 애플에서 제공하는 Access Token과 Refresh Token을 사용하지 않고 직접 관리했습니다. Access Token과 Refresh Token은 충분히 자체적으로 관리할 수 있고 외부 서비스 호출을 줄이기 위해서입니다.

Apple Login의 기본적인 절차는 아래와 같습니다. 위 링크 중 첫번째 링크만을 활용한 방식입니다.

  1. 사용자가 정보 허용 범위, Id, Password를 입력한다.

  2. 위의 값들과 함께 Apple Server로 요청을 보내면 Apple Server에서 사용자 정보를 담은 Identity Token을 제공한다.

  3. 해당 Identity Token와 함께 서버(Apple Server 아님)에 로그인 / 회원가입 api 호출을 한다.

Server 구현

1, 2는 iOS에서 처리하고 서버에서 처리해줘야하는 부분은 Identity Token을 받아서 Access Token을 발급해주면 되었습니다. 구조적인 부분은 첨부하지 않고 전체 흐름만 살펴보겠습니다.

아래의 흐름 대로 진행했습니다. 보시기 전에 JWT에 대해 공부를 좀 하시면 더 잘 이해하실 수 있을겁니다.

1. Apple IdToken header에 ALG와 KID 추출하기

Identity Token에 암호화 알고리즘인 ALG와 키 아이디인 KID 헤더가 존재하는데요. 이를 먼저 추출해줘야합니다.

@Component
public class JwtParser {

    private static final String TOKEN_VALUE_DELIMITER = "\\.";
    private static final int HEADER_INDEX = 0;
    private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();

    public Map<String, String> parseHeaders(String token) {
        try {
            String encodedHeader = token.split(TOKEN_VALUE_DELIMITER)[HEADER_INDEX];
            String decodedHeader = new String(Base64Utils.decodeFromUrlSafeString(encodedHeader));
            return OBJECT_MAPPER.readValue(decodedHeader, Map.class);
        } catch (JsonProcessingException | ArrayIndexOutOfBoundsException e) {
            throw UnauthorizedException.invalid();
        }
    }
}

2. Apple의 공개키 받아오기

[https://appleid.apple.com/auth/keys](https://appleid.apple.com/auth/keys) 경로로 API 요청을 보내서 실시간으로 변하는 Apple의 공개키를 응답받아야 합니다.

저는 Feign을 통해서 받아왔는데요. Feign을 사용했던 기록을 남겨두었습니다.

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "fh6Bs8C",
      "use": "sig",
      "alg": "RS256",
      "n": "u704gotMSZc6CSSVNCZ1d0S9dZKwO2BVzfdTKYz8wSNm7R_KIufOQf3ru7Pph1FjW6gQ8zgvhnv4IebkGWsZJlodduTC7c0sRb5PZpEyM6PtO8FPHowaracJJsK1f6_rSLstLdWbSDXeSq7vBvDu3Q31RaoV_0YlEzQwPsbCvD45oVy5Vo5oBePUm4cqi6T3cZ-10gr9QJCVwvx7KiQsttp0kUkHM94PlxbG_HAWlEZjvAlxfEDc-_xZQwC6fVjfazs3j1b2DZWsGmBRdx1snO75nM7hpyRRQB4jVejW9TuZDtPtsNadXTr9I5NjxPdIYMORj9XKEh44Z73yfv0gtw",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "kid": "W6WcOKB",
      "use": "sig",
      "alg": "RS256",
      "n": "2Zc5d0-zkZ5AKmtYTvxHc3vRc41YfbklflxG9SWsg5qXUxvfgpktGAcxXLFAd9Uglzow9ezvmTGce5d3DhAYKwHAEPT9hbaMDj7DfmEwuNO8UahfnBkBXsCoUaL3QITF5_DAPsZroTqs7tkQQZ7qPkQXCSu2aosgOJmaoKQgwcOdjD0D49ne2B_dkxBcNCcJT9pTSWJ8NfGycjWAQsvC8CGstH8oKwhC5raDcc2IGXMOQC7Qr75d6J5Q24CePHj_JD7zjbwYy9KNH8wyr829eO_G4OEUW50FAN6HKtvjhJIguMl_1BLZ93z2KJyxExiNTZBUBQbbgCNBfzTv7JrxMw",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "kid": "YuyXoY",
      "use": "sig",
      "alg": "RS256",
      "n": "1JiU4l3YCeT4o0gVmxGTEK1IXR-Ghdg5Bzka12tzmtdCxU00ChH66aV-4HRBjF1t95IsaeHeDFRgmF0lJbTDTqa6_VZo2hc0zTiUAsGLacN6slePvDcR1IMucQGtPP5tGhIbU-HKabsKOFdD4VQ5PCXifjpN9R-1qOR571BxCAl4u1kUUIePAAJcBcqGRFSI_I1j_jbN3gflK_8ZNmgnPrXA0kZXzj1I7ZHgekGbZoxmDrzYm2zmja1MsE5A_JX7itBYnlR41LOtvLRCNtw7K3EFlbfB6hkPL-Swk5XNGbWZdTROmaTNzJhV-lWT0gGm6V1qWAK2qOZoIDa_3Ud0Gw",
      "e": "AQAB"
    }
  ]
}

3. ALG, KID, Apple의 공개키로 security의 PublicKey 생성하기

@Component
public class AppleOAuthPublicKeyGenerator {

    private static final String ALG_HEADER_KEY = "alg";
    private static final String KID_HEADER_KEY = "kid";
    private static final int POSITIVE_SIGNUM = 1;

    public PublicKey generatePublicKey(Map<String, String> tokenHeaders, ApplePublicKeys applePublicKeys) {
        List<ApplePublicKey> publicKeys = applePublicKeys.getKeys();
        ApplePublicKey publicKey = publicKeys.stream()
                .filter(key -> key.getAlg().equals(tokenHeaders.get(ALG_HEADER_KEY)))
                .filter(key -> key.getKid().equals(tokenHeaders.get(KID_HEADER_KEY)))
                .findAny()
                .orElseThrow(UnauthorizedException::invalid);

        return generatePublicKeyWithApplePublicKey(publicKey);
    }

    private PublicKey generatePublicKeyWithApplePublicKey(ApplePublicKey applePublicKey) {
        byte[] n = Base64Utils.decodeFromUrlSafeString(applePublicKey.getN());
        byte[] e = Base64Utils.decodeFromUrlSafeString(applePublicKey.getE());
        RSAPublicKeySpec publicKeySpec =
                new RSAPublicKeySpec(new BigInteger(POSITIVE_SIGNUM, n), new BigInteger(POSITIVE_SIGNUM, e));

        try {
            KeyFactory keyFactory = KeyFactory.getInstance(applePublicKey.getKty());
            return keyFactory.generatePublic(publicKeySpec);
        } catch (NoSuchAlgorithmException | InvalidKeySpecException exception) {
            throw new ExternalServerException("응답 받은 Apple Public Key로 PublicKey를 생성할 수 없습니다.");
        }
    }
}

위에서 응답 받은 apple의 공개키들 중에서 Id Token에서 추출한 ALG와 KID가 동일한 키이 있습니다. 그 키의 N , E 값으로 KeySpec을 만듭니다. 애플은 다 RSA 방식이기 때문에 RSAPublicKeySpec을 생성해주면 됩니다. 그 후에 Kty 값으로 KeyFactory를 생성하고 KeyFactory와 KeySpec으로 PublicKey를 생성하면 됩니다.

4. Subject(사용자 식별값) 추출

생성된 PublicKey로 Identity Token의 Claim을 추출합니다.

@Component
public class JwtParser {

    //...

		public Claims parseClaims(String idToken, PublicKey publicKey) {
		    try {
		        return Jwts.parserBuilder()
		                .setSigningKey(publicKey)
		                .build()
		                .parseClaimsJws(idToken)
		                .getBody();
		    } catch (ExpiredJwtException e) {
		        throw UnauthorizedException.expired();
		    } catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
		        throw UnauthorizedException.invalid();
		    }
		}
}

이 상황에서 만료되었거나 유효하지 않은 토큰에 대한 예외가 발생하게 됩니다.

추출한 Claim에 Sub 값이 Apple에서 제공하는 사용자의 식별값입니다.

5. Identity Token 검증

공식 문서에서 클라이언트(iOS)에서 애플 서버통해 받은 Identity Token을 받아 아래 5가지를 검증해야 한다고 합니다.

To verify the identity token, your app server must:

  • Verify the JWS E256 signature using the server’s public key
  • Verify the nonce for the authentication
  • Verify that the iss field contains https://appleid.apple.com
  • Verify that the aud field is the developer’s client_id
  • Verify that the time is earlier than the exp value of the toke

모두 위의 Claim에 있는 값입니다. nonce, client_id는 Client에서 지정된 값입니다.

아래 메서드가 위 1 ~ 5 과정의 흐름입니다.

@Component
public class AppleOAuthClient implements OAuthClient {

    private final JwtParser jwtParser;
    private final AppleApiCaller appleApiCaller;
    private final AppleOAuthPublicKeyGenerator appleOAuthPublicKeyGenerator;
    private final AppleJwtClaimValidator appleJwtClaimValidator;

    // 생성자

    @Override
    public String getOAuthMemberId(String idToken) {
        Map<String, String> tokenHeaders = jwtParser.parseHeaders(idToken); // 1
        ApplePublicKeys applePublicKeys = appleApiCaller.getPublicKeys(); // 2
        PublicKey publicKey = appleOAuthPublicKeyGenerator.generatePublicKey(tokenHeaders, applePublicKeys); //3
        Claims claims = jwtParser.parseClaims(idToken, publicKey); //4
        validateClaims(claims); // 5
        return claims.getSubject();
    }

    private void validateClaims(Claims claims) {
        // ...
    }
}

6. Access Token 발급하기

위에서 Claim에 있는 Sub 값이 Apple에서 제공하는 사용자 식별 값입니다.

따라서, Sub 값을 아래 Member 객체의 oAuthId에 할당시켰습니다.

@Entity
public class Member {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "member_id")
    private Long id;

    private String oAuthId;

}

그 후에 Jwt Access Token을 클라이언트로 응답해주면 됩니다.

참고자료

https://hwannny.tistory.com/92

2개의 댓글

comment-user-thumbnail
2023년 8월 7일

도움 많이 됐습니다 감사합니다.

1개의 답글