스프링으로 Apple 로그인 이해하고 구현하기

koomin·2024년 7월 19일
6
post-thumbnail

스프링을 사용해서 BE 서버를 개발중이고 플러터를 사용해서 앱을 개발중에 있다. 이 과정에서 애플 로그인을 도입해야할 일이 생겨서 스프링으로 애플로그인을 구현해본 과정을 소개하고자 한다.

애플 로그인 플로우

위와 같은 플로우로 애플 로그인을 개발하였다. 클라언트에서 identity token을 서버에 주면 서버는 identity token 을 검사하고 올바르다면 자체 access token 을 발급해 클라이언트에 넘겨주는 방식이다. identity token 은 밑에서 좀 더 자세히 설명한다. 여기서 identity token 을 검사하는 방식이 조금 복잡하다. 이 부분에 대해서도 아래에서 자세히 설명한다.

Identity Token

플러터에서 sign_in_with_apple 을 사용해서 애플 로그인을 하면 애플 서버에서 identity token 과 authorization code 를 넘겨준다. authorization code 는 apple 서버로 부터 access token 을 받기 위한 코드인데 이번에는 사용하지 않기 때문에 넘어가겠다.

identity token 은 JSON 웹 토큰 (JWT) 이다. 이 안에는 애플 공식 문서에 따르면 아래와 같은 정보가 있다.

  • iss : https://appleid.apple.com.
  • sub : 유저의 고유한 id
  • aud : 앱 id
  • iat : 발행일
  • exp : 만료일
  • email : 유저의 이메일

더 자세한 정보는 Authenticating users with Sign in with Apple | Apple Developer Documentation 를 참고하자.

우리는 소셜 로그인 할 때 이메일과 provider 정보를 기반으로 동작하기 때문에 identity token 만 있어도 충분히 사용자의 신원을 확인 할 수 있다. 때문에 apple 의 access token 이 필요없기 때문에 authorization code 를 사용하지 않는다. 서버에서 identity token 을 정확히 검사해야한다. 그래야 악성 요청을 막을 수있다.

Identity Token을 검사하는 방법

어떻게 identity token 을 검사해야할까? 답은 애플 공식 문서에 나와있다. 애플 공식 문서에 따르면 아래 항목을 검사하라고 한다.

  • 서버의 공개 키를 사용하여 JWS E256 서명을 검증합니다.
  • 인증을 위해 nonce를 검증합니다.
  • iss 필드에 https://appleid.apple.com이 포함되어 있는지 확인합니다.
  • aud 필드가 개발자의 client_id인지 확인합니다.
    • Bundle Identifier 과 일치하는지 확인 하면된다.
  • 현재 시간이 토큰의 exp 값보다 이전인지 확인합니다.

“서버의 공개 키를 사용하여 JWS E256 서명을 검증합니다.” 이 부분이 나는 가장 어려웠다. identity token이 jwt 토큰이니 signature 값을 검사하라는 뜻이다.

서명을 검증 하는 법

identity token 의 header 에서 alg, kid 추출하기

identity token 은 jwt 이기 때문에 base64로 인코딩 한것을 디코딩하면

{
  "kid": "---------",
  "alg": "RS256"
}

로 되어있는 것을 확인 할 수 있다. 위 kid 값과 alg 값을 추출한다.

Public Key 가져오기

https://appleid.apple.com/auth/keys 주소로 GET 요청을 보내면

{
  "keys": [
    {
      "kty": "RSA",
      "kid": "pggnQeNCOU",
      "use": "sig",
      "alg": "RS256",
      "n": "xyWY7ydqTVHRzft5fPZmTuD9Ahk7-_2_IekZGy07Ovhj5IhYyVU8Hq5j0_c9m9tSdJTRdKmNjMURpY4ZJ_9rd3EOQ_WnYHM2cZIQ5y3f_WxeElnv_f2fKDruA-ERaQ6duov-3NAXC3oTWdXuRGRLbbfOVCahTjvnAA8YBRUe3llW7ZvTG14g-fAEQVlMYDxxCsbjtBJiUzKxbH-8KvhIhP9AJtiLDfiK1yzVJ7Qn6HNm5AUsFQKOAgTqxDMJkhi7pyntTyxhpkLYTEndaPRXth_LM3hVmaoFb3P3TsPCbDjSEbKy1wAndfPSzUk6qjyyBYhdXH0sgVpKMBAdggylLQ",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "kid": "T8tIJ1zSrO",
      "use": "sig",
      "alg": "RS256",
      "n": "teUbLrwScsjVrcFAvSrfben3eQaEca3ESBegGh_wdGuLKw6QgwDxY3fC1_WeSVnkJXx72ddw3j2inoADnTyzuNa_PwDSmvJhOhmzOmoltmtKHteGdaXrqMohO6A85WxVKbN7pzDqwZJNrdY12LOltlI8PHIG-elAbKM2XOHiJaZnLpAVckKy6MQYsEExpPB3plGxWZElqwNZY6SUDVeN-o9qg5FJOFg7T7iTVVEagws4DM6uZNMDQGtqg9V9VqPQkUzC-sYd5eqbB9LqH4iN5F6OB7BmD3g3jCu9zgh3O9V24N43EruBCNrmP0xLP5ZliKqozoAcd1nv71HuVm6mgQ",
      "e": "AQAB"
    },
    {
      "kty": "RSA",
      "kid": "pyaRQpAbnY",
      "use": "sig",
      "alg": "RS256",
      "n": "qHiwOpizi6xHG8FIOSWH4l0P1CjLIC7aBFkhbk7BrD4s9KQAs5Sj5xAtOwlZMyP2XFcqRtZBLIMM7vw_CNERtRrhc68se5hQE_vsrHy7ugcQU6ogJS6s54zqO-zTUfaa3mABM6iR-EfgSpvz33WTQZAPtwAyxaSLknHyDzWjHEZ44WqaQBdcMAvgsWMYG5dBfnV-3Or3V2r1vdbinRE5NomE2nsKDbnJ3yo3u-x9TizKazS1JV3umt71xDqbruZLybIrimrzg_i9OSIzT2o5ZWz8zdYkKHZ4cvRPh-DDt8kV7chzR2tenPF2c5WXuK-FumOrjT7WW6uwSvhnhwNZuw",
      "e": "AQAB"
    }
  ]
}

위와 같은 형식으로 응답을 받을 수 있다. 3개의 공개키가 응답으로 오는데 우리가 필요한 것은 identity token 의 kid와 alg 가 일치하는 키 값이다. 따라서 3개중 일치하는 하나의 키 값을 가져온다.

위 JSON 데이터는 RSA 공개 키를 나타내는 JWK (JSON Web Key) 형식 이라고 한다.

Identity token의 signature 검증을 위한 public key 생성

위 jwk 에서 ne 값을 가져온다. ne 값을 사용해 public key 를 생성한다.
public key 생성하는 방법은 너무 복잡하기 때문에 여기서 설명하지는 않겠다. 궁금하다면 RSA, 제대로 이해하기 (1) 를 참고해라.

  • n (Modulus): RSA 공개 키의 모듈러이다. Base64url 인코딩된 문자열이다.
  • e (Exponent): RSA 공개 키의 지수이다. Base64url 인코딩된 문자열이다.

생성한 public key를 사용해 Identity token의 signature를 검증

마지막으로 jwt 을 검증하면 끝이다.

구현하기

이제 코드로 어떻게 구현하는지 설명하겠다. OAuth 2.0, JWT를 활용한 애플 로그인 구현 (2-구현편) 위 블로그의 코드를 참고하여 구현했다. identity token 을 검증 하는 코드위주로 설명한다.

private void verifyIdentityToken(String identityToken) throws
    JsonProcessingException,
    NoSuchAlgorithmException,
    InvalidKeySpecException {
    // jwt 헤더를 파싱한다.
    Map<String, String> headers = jwtProvider.parseHeaders(identityToken);
    // 공개키를 생성한다
    PublicKey publicKey = applePublicKeyGenerator.generatePublicKey(headers, getAppleAuthPublicKey());
    // 토큰의 서명을 검사하고 Claim 을 반환받는다.
    Claims tokenClaims = jwtProvider.getTokenClaims(identityToken, publicKey);
    // iss 필드 검사
    if (!issuer.equals(tokenClaims.getIssuer())) {
       throw new AppException(ErrorCode.INVALID_JWT);
    }
    // aud 필드 검사
    if (!clientId.equals(tokenClaims.getAudience())) {
       throw new AppException(ErrorCode.INVALID_JWT);
    }
}
// Public Key를 가져온다.
private ApplePublicKeyResponse getAppleAuthPublicKey() {
		return restClient.get()
			.uri(applePublicKeysUrl)
			.retrieve()
			.body(ApplePublicKeyResponse.class);
	}

위 코드가 identity token 을 검증하는 메서드이다. 코드의 흐름을 잘 살펴보면 위에서 설명한 흐름 순으로 진행되는 것을 알 수 있다.

위 메서드에 쓰인 객체들에 대해서도 설명하겠다.

ApplePublicKeyGenerator

@Component
@RequiredArgsConstructor
public class ApplePublicKeyGenerator {
	public PublicKey generatePublicKey(Map<String, String> tokenHeaders,
		ApplePublicKeyResponse applePublicKeys) throws NoSuchAlgorithmException, InvalidKeySpecException {
		ApplePublicKey publicKey = applePublicKeys.getMatchedKey(tokenHeaders.get("kid"),
			tokenHeaders.get("alg"));
		return getPublicKey(publicKey);
	}
	private PublicKey getPublicKey(ApplePublicKey publicKey)
		throws NoSuchAlgorithmException, InvalidKeySpecException {
		byte[] nBytes = Base64.getUrlDecoder().decode(publicKey.n());
		byte[] eBytes = Base64.getUrlDecoder().decode(publicKey.e());
		RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(new BigInteger(1, nBytes),
			new BigInteger(1, eBytes));
		KeyFactory keyFactory = KeyFactory.getInstance(publicKey.kty());
		return keyFactory.generatePublic(publicKeySpec);
	}
}

public key를 생성하는 작업을 수행한다.

getPublicKey 가 실제 public key 를 생성하는 부분이다. n 과 e 를 사용해 생성한다.

DTO

public record ApplePublicKey(String kty, String kid, String alg, String n, String e) {
}
public record ApplePublicKeyResponse(List<ApplePublicKey> keys) {
	public ApplePublicKey getMatchedKey(String kid, String alg) throws AppException {
		return keys.stream()
			.filter(key -> key.kid().equals(kid) && key.alg().equals(alg))
			.findAny()
			.orElseThrow(() -> new AppException(ErrorCode.INVALID_JWT));
	}
}

JwtProvider

@Component
@RequiredArgsConstructor
public class JwtProvider {
	public Map<String, String> parseHeaders(String token) throws JsonProcessingException {
		String header = token.split("\\.")[0];
		return new ObjectMapper().readValue(decodeHeader(header), Map.class);
	}
	private String decodeHeader(String token) {
		return new String(Base64.getDecoder().decode(token), StandardCharsets.UTF_8);
	}
	public Claims getTokenClaims(String token, PublicKey publicKey) {
		try {
			return Jwts.parser()
				.setSigningKey(publicKey)
				.parseClaimsJws(token)
				.getBody();
		} catch (SignatureException | MalformedJwtException e) {
			throw new AppException(ErrorCode.INVALID_JWT);
		} catch (ExpiredJwtException e) {
			throw new AppException(ErrorCode.JWT_EXPIRED);
		}
	}
}

jwt에 관련된 작업을 수행한다.

  • parseHeaders : 헤더의 내용을 Map 형태로 파싱해 반환한다.
  • getTokenClaims : token 의 서명을 publicKey 를 사용해 검증하고 Claim 을 반환한다.

마치며..

배포를 하지 않는다면 여기까지 구현해도 크게 문제는 없다고 생각한다. 하지만 굳이 애플 로그인을 도입하기위해 여기까지 보신 분들이라면 출시가 목표일 것이라고 생각한다. 그렇다면 이 정도로 충분하지 않다. 다음 글에서 출시를 위해서 어떤 부분을 더 살펴 보아야할지 알아보도록 하겠다.

profile
개발 지식 수집하기. 직접 경험해본 내용을 기록합니다.

1개의 댓글

comment-user-thumbnail
2024년 8월 1일

프로젝트 진행하면서 애플 로그인 구현 때문에 막막했는데 덕분에 도움 많이 받았습니다!!

답글 달기