애플 로그인은 구글이나 국내 소셜 로그인과는 다른 방식으로 구현해야 한다.
관련 포스트를 참고해 구현했지만, 일부 포스트는 필요 이상으로 의존성을 사용했고 무엇보다도 내 방식으로 정리를 해놔야 나중에 애플 로그인이 기억나지 않을 때 빠르게 이해하려고 작성했다.
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/generate_and_validate_tokens
애플 인증 서버에서 제공하는 access 토큰은 5분으로 매우 짧았기 때문에, 앱을 사용하면 5분마다 재인증을 해야 하는 문제가 있었다.
또한, refresh 토큰은 첫 response 후 다시 제공하지 않은 문제가 있었다.
refresh 토큰이 노출되면 해당 사용자를 탈퇴시키고 재가입해야 하는 극단적인 방법이었고, 우리 서비스에선 refresh 토큰도 일정 주기마다 새로 발급하도록 만들었다.
그래서 자체적으로 인증 토큰을 만들어 관리했고, 애플 로그인 구현 시 해당 부분까진 구현하지 않았다.
Reference
https://peterica.tistory.com/85
https://hwannny.tistory.com/71
'개발' 카테고리의 다른 글
서버에서 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 |