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

들어가며

 

사이드 프로젝트를 진행하던 도중 APP에서 Apple 로그인을 적용해야 했다.

 

https://apps.apple.com/kr/app/%EA%B8%80%EC%9D%84%EB%8B%B4%EB%8B%A4/id1517289762

 

‎글을담다

‎마음 속 와 닿은 글을 손쉽게 담는, '글을담다' 내가 본 책, 영화, 드라마, 음악 속 글을 수집해 보세요 감성적인 디자인과 함께해요 이런 기능을 가지고 있어요 ■ 감성적인 디자인으로 문학을

apps.apple.com

https://play.google.com/store/apps/details?id=com.bestbranch.geulbox&hl=ko&gl=US 

 

글을담다 - Google Play 앱

마음 속 와 닿은 글을 담아봐요

play.google.com

 

앱에서 Apple 로그인 성공 이후 백단에선 처리해야 할 내용을 포스팅하고자 한다.

 

 

 

 

필요성

 

의문을 하나 가질 수 있을것이다. "앱에서 이미 로그인 과정을 거치는데 로그인 후 서버에서 인증 관련하여 처리할 것이 있을까?"

앱으로 부터 요청을 받는 API 서버의 프로토콜은 보통 HTTP(S) 이다. 즉, 서버는 우리가 개발한 앱이 아닌 다른 것으로부터 요청을 받을 수도 있다.

따라서 서버내에 별도의 인증과정을 거치지 않는다면 아무 사용자나 회원 관련 중요한 정보를 조회하거나 수정, 삭제등을 할 수 있을 것이다.

 

 

인증 과정

 

상세한 인증과정을 설명하기 앞서 대략적으로 어떻게 인증이 이루어지는지 그림으로 큰 개념을 표현해 봤다.

 

 

 

iOS 앱으로 부터 요청 받기

 

APP 측에서 Apple 로그인을 성공하면 Apple ID Server로 부터 다음과 같은 회원정보를 응답 받는다.

https://developer.apple.com/documentation/authenticationservices/asauthorizationappleidcredential

 

Apple Developer Documentation

 

developer.apple.com

 

문서에 명시되어 있는 응답 목록중 identityToken과 authorizationCode을 찾을 수 있다. 

 


 

Identity Token

 

identityToken은 JWT 형식이며 아래의 링크에

Retrieve the User’s Information from Apple ID Servers 목차를 살펴보면 구성되어있는 payload를 확인할 수 있다.

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

 

Apple Developer Documentation

 

developer.apple.com

 

따라서 identity Token을 통해 API Server측에서 중요한 유저 정보를 얻을 수 있다. (apple 고유 계정 id, email 등)

 


Authorization Code

 

identityToken은 생성 후 10분 뒤 만료되기 때문에 identityToken가지고는 APP측에서 session을 유지하기 어렵다. 그래서 authorizationCode를 통해 APP측에서 session을 유지시킬 수 있는 token을 Apple ID Server로 부터 발급받아야 한다. 

추가로 authorizationCode는 생성 후 5분 뒤 만료된다.

 

 

서버에서 Identity Token는 어떻게 검증할까?

 

아래의 링크를 살펴보면 서버측에서 Identity Token을 어떻게 검증해야할지에 대한 자세한 내용이 쓰여있다.

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

 

Apple Developer Documentation

 

developer.apple.com

 

 

 

 

identity Token내 payload에 속한 값들이 변조되지 않았는지 검증하기 위해서는 애플 서버의 public key를 사용해 JWS E256 signature를 검증해야한다.

-> 'Verify the JWS E256 signature using the server's pulbic key'

 

public key를 가져오는 내용은 아래의 링크를 참조하면 된다.

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

 

Apple Developer Documentation

 

developer.apple.com

 

public key를 조회하면 다음과 같이 두개의 키를 응답 받을 것이다. 사실 publick key를 만들 수 있는 구성요소, 정보들이라고 봐야한다.

 

 

 

 

두개 키 각각의 kid, alg 중 Identity Token header에 포함된 kid, alg와 일치하는 key를 사용하면된다.

JWT의 header 값은 base64로 인코딩되어 있기 때문에 디코딩해보면

 

 

 

 

위와 같은 값을 가지며 public key 중 두번째 키를 사용하면 된다는 것을 알 수 있다.

 

암호화된 알고리즘은 'RS256' 즉, SHA-256을 사용하는 RSA(비대칭키 암호화방식)이기 때문에 n(modulus), e(exponent)로 공개키를 구성한다.

https://johngrib.github.io/wiki/rsa-encryption/

 

RSA 암호(RSA Encryption)

 

johngrib.github.io

 

즉, n, e 값을 통해 public key를 생성한뒤 public key로 Identity Token의 서명(signature)을 검증하면 된다.

 

 

Signature 검증하기 (Identity Token 검증하기)

 

public key를 조회할 Client와 응답 받은 내용을 담을 DTO를 작성한다.

 

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

}

 

@Getter
@Setter
public class ApplePublicKeyResponse {
    private List<Key> keys;
    
    @Getter
    @Setter
    public static class Key {
        private String kty;
        private String kid;
        private String use;
        private String alg;
        private String n;
        private String e;
    }
    
    public Optional<ApplePublicKeyResponse.Key> getMatchedKeyBy(String kid, String alg) {
        return this.keys.stream()
                        .filter(key -> key.getKid().equals(kid) && key.getAlg().equals(alg))
                        .findFirst();
    }
}

 

다음은 위에서 작성된 Client를 이용해 public key 구성요소를 조회한 뒤 JWT의 서명을 검증한 후 Claim을 응답하는 코드이다.

굉장히 간단하다.

 

@Component
@RequiredArgsConstructor
public class AppleJwtUtils extends JwtUtils {
	private final AppleClient appleClient;
	
	@Override
	public Claims getClaimsBy(String identityToken) 
		
	try {
            ApplePublicKeyResponse response = appleClient.getAppleAuthPublicKey();
            
            String headerOfIdentityToken = identityToken.substring(0, identityToken.indexOf("."));
            Map<String, String> header = new ObjectMapper().readValue(new String(Base64.getDecoder().decode(headerOfIdentityToken), "UTF-8"), Map.class);
            ApplePublicKeyResponse.Key key = response.getMatchedKeyBy(header.get("kid"), header.get("alg"))
				.orElseThrow(() -> new NullPointerException("Failed get public key from apple's id server."));
		
            byte[] nBytes = Base64.getUrlDecoder().decode(key.getN());
            byte[] eBytes = Base64.getUrlDecoder().decode(key.getE());

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

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

            return Jwts.parser().setSigningKey(publicKey).parseClaimsJws(identityToken).getBody();
            
        } catch (NoSuchAlgorithmException e) {
        } catch (InvalidKeySpecException e) {
        } catch (SignatureException e | MalformedJwtException e) {
        //토큰 서명 검증 or 구조 문제 (Invalid token)
        } catch (ExpiredJwtException e) {
        //토큰이 만료됐기 때문에 클라이언트는 토큰을 refresh 해야함.
        } catch (Exception e) {
        }
}

 

참고로 응답받은 n, e 값은 base64 url-safe로 인코딩 되어 있기 때문에 반드시 디코딩하고나서 public key로 만들어야 한다.

 

 

 

 

 

이후 성공적으로 서명이 검증됐다면 Identity token의 payload 들이 신뢰할 수 있는 값들이라는 증명이 완료된것이다.

인코딩 되어있는 payload를 디코딩하여 apple 고유 계정 id 등 중요 요소를 획득해 사용하면된다. 그리고 필요에 따라 iss, aud 등 나머지 값들을 추가적으로 검증하면 된다.

 

다시말하지만!!! 여기서 끝이면 좋겠지만 id_token은 만료시간이 10분이기 때문에 APP session을 유지하기에는 굉장히 짧은시간이다. 만료가 되버리면 유저는 다시 앱에서 애플 로그인을 시도해야한다. (Face ID, Touch ID 등) 그러므로 id_token이 검증이 됐다면 서버측에서 뒤이어 토큰까지 발급받아 APP에 응답해주어야한다. 이후 APP은 발급받은 토큰으로 session을 유지하면된다.

 

 

Authorization Code로 Token 발급 받기

 

APP에서 session을 유지하기위해 필요한 token을 발급 받기 위해서는 아래 내용의 API를 사용하면된다.

 

https://appleid.apple.com/auth/token

 

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

 

Apple Developer Documentation

 

developer.apple.com

요청에 필요한 요소들을 살펴보면 code(Authorization Code) 뿐만아니라, client_id, client_secret, grant_type이 필요하다.

 


code

 

APP으로 부터 넘겨받은 Authorization Code 이다.

 

client_id

 

Apple Developer 페이지에 App Bundle ID를 말한다. ex) com.xxx.xxx 형식이다.

 

grant_type

 

"authorization_code" 값을 주면된다.

 

client_secret

 

문서에 따르면 client_secret은 다음과 같은 내용을 포함하여 JWT형식의 토큰을 생성해야한다. 

 

{
    "alg": "ES256",
    "kid": "ABC123DEFG"
}
{
    "iss": "DEF123GHIJ",
    "iat": 1437179036,
    "exp": 1493298100,
    "aud": "https://appleid.apple.com",
    "sub": "com.mytest.app"
}

 

alg는 ES256를 사용한다.

kid는 Apple Developer 페이지에 명시되어있는 Key ID 이다.

 

issApple Developer 페이지에 명시되어있는 Team ID 이다. (우측 상단에 있음.)

iat는 client secret이 생성된 일시를 입력한다. (현재시간을 주면 된다)

exp는 client secret이 만료될 일시를 입력한다. (현재시간으로 부터 15777000초, 즉 6개월을 초과하면 안된다.)

aud는 "https://appleid.apple.com" 값을 입력한다.

sub는 위에서 얘기한 client_id 값을 입력한다. com.xxx.xxx 와 같은 형식이다.

 

다음은 client_secret을 생성하는 실제 코드이다.

 

public String makeClientSecret() throws IOException {
    Date expirationDate = Date.from(LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant());
    return Jwts.builder()
               .setHeaderParam("kid", keyId)
               .setHeaderParam("alg", "ES256")
               .setIssuer(teamId)
               .setIssuedAt(new Date(System.currentTimeMillis()))
               .setExpiration(expirationDate)
               .setAudience("https://appleid.apple.com")
               .setSubject(clientId)
               .signWith(SignatureAlgorithm.ES256, getPrivateKey())
               .compact();
    }
    
private PrivateKey getPrivateKey() throws IOException {
    ClassPathResource resource = new ClassPathResource("Apple_Developer_페이지에서_다운.p8");
    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);
}

 

추가로 sign을 할 때 필요한 private key는 Apple Developer 페이지에서 다운로드 받은 확장자가 .p8 인 파일을 사용하면된다.

 

이제 토큰을 발급받기 위한 준비가 끝났으니 요청하는 client 코드와 request, response 모델도 추가해주자.

 

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

	//아래 내용 추가
	@PostMapping(value = "/token", consumes = "application/x-www-form-urlencoded")
	AppleToken.Response getToken(AppleToken.Request request);
}

 

public class AppleToken {

	@Setter
	public static class Request {
		private String code;
		private String client_id;
		private String client_secret;
		private String grant_type;
		private String refresh_token;

		public static Request of(String code, String clientId, String clientSecret, String grantType, String refreshToken) {
			Request request = new Request();
			request.code = code;
			request.client_id = clientId;
			request.client_secret = clientSecret;
			request.grant_type = grantType;
			request.refresh_token = refreshToken;
			return request;
		}
	}

	@Setter
	public static class Response {
		private String access_token;
		private String expires_in;
		private String id_token;
		private String refresh_token;
		private String token_type;
		private String error;

		public String getAccessToken() {
			return access_token;
		}

		public String getExpiresIn() {
			return expires_in;
		}

		public String getIdToken() {
			return id_token;
		}

		public String getRefreshToken() {
			return refresh_token;
		}

		public String getTokenType() {
			return token_type;
		}
	}
}

 

code, client_id, client_secret, grat_type과 함께 토큰 발급 요청을 하면 다음과 같은 결과를 얻는다.

(참고로 code 값인 authorizationCode는 요청에 1번 밖에 쓸 수 없다.)

 

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

 

 

일반적으로 응답받은 access_token을 session유지에 사용하겠지만 우리가 필요한건 access_token이 아닌 refresh_token이다.

그 이유는 애플은 현재 access_token을 통해 무언가 인증해줄 서비스를 제공해주고 있지 않다.

무슨 생각인지는 모르겠지만 결국 우리는 APP측에서 refresh_token을 가지고 session을 유지해야한다.

 

 

Refresh Token을 통해 인증하기

 

앞에 내용들로 refresh token을 얻었다면 로그인 또는 인증이 필요할 때마다 refresh token을 통해 access token을 재발급 받아보는 형식으로 refresh token이 유효한지 검증해주면 된다. 

(참고로 refresh token은 만료시간이 없다.. 애플은 진짜 무슨생각일까..)

 

다음은 refresh token으로 인증하는 대략적인 그림이다.

 

 

access token 재발급은 기존 token을 발급 받기 위해 요청했던 동일한 API를 사용하면 된다.

 

https://appleid.apple.com/auth/token

 

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

 

Apple Developer Documentation

 

developer.apple.com

 

대신 요청할 때 필요한 요소들이 약간 다르다.

 

 

refresh_token

 

APP으로 부터 넘겨받은 refresh_token 이다.

 

client_id

 

앞서 나왔던 내용과 동일하다.

 

grant_type

 

"refresh_token" 값을 주면된다.

 

client_secret

 

앞서 나왔던 내용과 동일하다.

 

실제 요청하면 다음과같은 결과를 얻는다.

 

{
    "access_token": "....",
    "token_type": "Bearer",
    "expires_in": 3600
}

 

이상없이 access_token이 재발급됐다면 refresh_token을 넘긴 APP측은 인증됐다고 판단하면된다.

 

 

결론

 

access_token은 실제 인증에 사용되지도 않고, refresh_token도 만료시간이 존재하지 않으니 다시 말하지만 애플은 무슨생각인지 잘모르겠다. 그리고 개발자가 직접 Apple ID Server에 인증 요청하기 전 생성 해줘야 할 필요한 요소들도 많아 third-party 로그인 중에 가장 까다롭지 않나 싶다.

 

그리고 refresh_token은 만료되지 않기 때문에 권한이 필요한 요청일 경우 굳이 매번 애플 ID 서버로부터 refresh_token을 통해 access_token을 발급 받기보다는 유저의 refresh_token을 따로 DB나 기타 저장소에 저장해두고 캐싱해두고 조회해서 검증하는편이 성능면에서 나을것이다.

 

+ 더 나은 인증플로우가 있다면 소중한 피드백 부탁드립니다. 🙏🏻