애플 회원탈퇴 까지 구현해서 앱스토어 심사통과하기

koomin·2024년 7월 29일
5
post-thumbnail
post-custom-banner

이전 글에서 Identity Token을 사용해서 로그인을 구현하는 방법을 소개했다. 그 당시에는 애플 서버에서 자원을 활용할 일이 없어 단순히 Identity Token의 유효성을 확인 하고 Identity Token 안에 들어있는 이메일 값을 사용하여 스프링 서버에서 자체적인 로그인과 회원가입을 처리하는 flow로 로그인을 구현하였었다.

회원 탈퇴의 필요성

앱 스토어에 배포를 해야하는 찰나에 심사에 통과하려면 회원 탈퇴 기능이 필수 라는 것을 알게되었다. 우리는 회원 탈퇴를 구현해두었기 때문에 안심했다. 하지만 애플은 그렇게 쉬운 놈들이 아니었다. 애플로 로그인 한 경우 회원 탈퇴 시 애플 사용자 토큰을 해지 해야만했다.

애플 계정 삭제 지침 페이지를 확인 해보면 위와 같이 토큰을 취소 하라고 나온다.

Identity Token 만을 사용하는 방식의 문제점

결론부터 말하자면 Identity Token 만을 사용해서는 사용자 토큰을 취소 할 수 없다. 토큰을 취소하기위해서는 취소 요청을 REST API 로 보내야한다. 보내기 위해서는 token 값이 필요한데 이 값은 authorization code 가 있어야만 얻을 수 있다.

💡 authorization code 는 플러터에서 sign_in_with_apple 를 사용하여 로그인을 하면 identity token 과 함께 얻을 수 있는 값이다. authorization code 는 유효기간이 5분으로 짧고 한번 밖에 사용할 수 없기 때문에 주의하여 사용해야한다.

결국 identity token 만 사용하는 것이 아니라 authorization code 까지 사용하도록 코드 수정 해야했다.

사용자 토큰을 해지하는 방법

지금 부터 구체적으로 어떻게 사용자 토큰을 해지하는지 설명하겠다.

Verifying a user | Apple Developer Documentation

Creating a client secret | Apple Developer Documentation

Generate and validate tokens | Apple Developer Documentation

Revoke tokens | Apple Developer Documentation

위 문서들을 모두 참고해야 구현 할 수 있다. 우선 전체적인 흐름을 설명하고 각 단계별 세부적으로 설명하겠다.

전체 Flow

회원가입

1 ~ 6 번까지의 과정은 이전 글에서 적은 과정과 동일하다. 애플로부터 사용자가 로그인 하고 identity tokenauthorization code 를 받아 api 서버에 보내고 api 서버가 identity token 을 검증한다.

7번 과정에서는 identity token 에서 email 을 받아오고 해당값으로 새로운 user 를 생성한다.

8 ~ 11 번 과정은 애플 토큰을 얻어 서버에 저장하는 과정이다. client secret 이라는 것을 직접 서버에서 생성한다. 이 client secretauthorization code 와 함께 Apple 서버에 보내면 access token 과 refresh token 을 발급하여 응답 한다. 이중 refresh token 을 DB 에 저장한다.

마지막으로 api 서버에서 자체 발급한 access token 과 refresh token 을 client 에 응답 해주는 것으로 회원가입 절차가 끝난다.

회원 탈퇴

회원가입 때 발급 받은 refresh token 을 가지고 Apple 서버에 토큰 취소 요청을 날린다. 그 후 회원 정보를 DB에서 삭제하는 플로우로 탈퇴 과정이 진행된다.

상당히 복잡한 과정이지만 하나 하나 세세하게 설명해보겠다. 회원가입 1~ 6 번까지의 과정은 이전 글을 참고해 주길 바란다.

Client Secret 생성하기

8번 에 해당하는 내용이다. client secret 이라는 것을 생성해야한다. client secret 이 필요한 이유는 Token을 생성하기 위해 REST API 요청을 보내야하는데 body 값에 client secret 을 필수로 포함하게 되어있다.

client secret 은 JWT 이다. 따라서 JWT 표준에 맞게 만들 수가 있다. 필요로 하는 요소는 다음과 같다.

  • header
    • alg : “ES256”
    • kid : 애플에서 발급 받은 private key 의 id
  • payload
    • iss : 개발자 계정의 team id
    • iat : 발급 시간
    • exp : 만료 시간 → 15777000 (6달) 보다 길면 에러가 발생한다.
    • aud : “https://appleid.apple.com”
    • sub : App id

위의 요소가 필요하다. kid, iss, sub 는 애플 developer 홈페이지에서 찾을 수 있다.

iss, sub

개발자 사이트의 Certificates, Identifiers & Profiles 의 Identifiers 페이지에서 찾을 수 있다. 본입 앱에 해당하는 IDENTIFIER 를 찾아 클릭하면

위와 같은 화면이 나오는데 App ID Prefix 에 해당하는 것이 iss 에 해당하고 Bundle ID 가 sub 에 해당한다.

kid

kid 는 이 client secret 을 서명할 private key의 id 이다. private key 는 개발자 사이트의 Certificates, Identifiers & Profiles 에서 Keys 에 들어가서 + 버튼을 눌러 만든다.

Sign in with Apple 을 선택하고 옆에 Configure 버튼을 누른다.

Primary App ID 를 우리 앱에 맞는 것으로 선택 후 continue 를 누르면 key 가 만들어진다.

스크린샷 2024-07-29 오후 6.53.23.png

Download 버튼을 눌러 key 다운로드 한다. .p8 확장자로 다운된다. 이때 키는 한번만 다운로드 할 수 있으니 다운 후 잘 보관해두어야한다. 그리고 Key ID 가 kid 의 값이다.

이제 header 와 payload 를 다 채웠다면 위에서 발급 받은 key 로 서명해주면 client secret 완성이다.

구현

이제 client secret 을 구현하는 방법을 설명한다.

@Component
@RequiredArgsConstructor
public class AppleKeyGenerator {
	private final RestClient restClient;
	@Value("${oauth.apple.url.public-keys}")
	private String applePublicKeysUrl;
	@Value("${oauth.apple.app.keyId}")
	private String kid;
	@Value("${oauth.apple.app.teamId}")
	private String teamId;
	@Value("${oauth.apple.app.id}")
	private String appId;
	@Value("${oauth.apple.app.privateKey}")
	private String privateKey;

	/**
	 * apple client secret 을 생성한다.
	 * @return
	 * @throws IOException
	 */
	public String getClientSecret() {
		Date expirationDate = Date.from(LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant());

		return Jwts.builder()
			.setHeaderParam("kid", kid)
			.setHeaderParam("alg", "ES256")
			.setIssuer(teamId)
			.setIssuedAt(new Date(System.currentTimeMillis()))
			.setExpiration(expirationDate)
			.setAudience("https://appleid.apple.com")
			.setSubject(appId)
			.signWith(SignatureAlgorithm.ES256, getPrivateKey())
			.compact();
	}

	/**
	 * apple private 키를 반환한다.
	 * @return
	 * @throws IOException
	 */
	private PrivateKey getPrivateKey() {
		try {
			Reader pemReader = new StringReader(privateKey.replace("\\n", "\n"));
			PEMParser pemParser = new PEMParser(pemReader);
			JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
			PrivateKeyInfo object = (PrivateKeyInfo)pemParser.readObject();
			return converter.getPrivateKey(object);
		} catch (IOException e) {
			throw new AppException(ErrorCode.INTERNAL_SERVER_ERROR);
		}
	}
}

의존성은 아래의 것들을 추가 해서 사용했다.

implementation 'org.bouncycastle:bcpkix-jdk15on:1.69'
implementation 'org.bouncycastle:bcprov-jdk15on:1.69'
implementation group: 'io.jsonwebtoken', name: 'jjwt', version: '0.9.1'

가장 어려웠던 부분이 애플에서 다운 받은 .p8 파일을 가지고 서명 하는 것이었다. PEMParserJcaPEMKeyConverter 를 사용하여 구현했다. 그리고 .p8 파일을 프로젝트 내에 가지고 있으면 깃에 올릴 때 유출되기 때문에 환경변수로 변경했다.

.p8 파일을 vs code 나 텍스트 에디터에서 열면 안에 적힌 문자열을 볼 수 있다. 열어보면

-----BEGIN PRIVATE KEY-----
uihiojopdfnopnsddjnvieornnerpnervnsprvnpsdnrpsndofodnsfnisdfivsdndi
aksdbfuiasfbuiasuiwsodcnsdlngfhbiorthjeiorthdfbniosdniofvwnodfhnioh
qweqwefqwefqwegqergsdafsdfbrthrtyj7yukrtes4ewerhherhertherthertherth
aweqergergerth
-----END PRIVATE KEY-----

이런식으로 되어있다. 이 문자열을 환경변수에 넣어야되는데 환경변수에서 줄바꿈을 잘 인식 하지 못했다. 그래서 줄바꿈 되는 부분을 \n 으로 치환해서 넣었다.

이렇게 넣으니 또 스프링에서 private key를 인식 못하는 문제가 발생했다. 알고보니 환경변수에서 \n 을 넣어줘도 읽어올땐 \\n 으로 읽어왔다. 그래서 privateKey.replace("\\n", "\n") 를 사용해서 \\n\n 로 바꾸어 해결했다.

Token 생성하기

client secret 을 만들었다면 이제 token 만 받아오면 된다. 공식 문서에 나온 스펙은 다음과 같다.

URL

POST https://appleid.apple.com/auth/oauth2/v2/token

  • client id 는 app id 를 넣으면 된다. client secret 만들때 사용되었던 sub 값이 되겠다.
  • client secret 은 위에서 만들었던 값을 넣는다.
  • grant typeauthorization code 를 사용 할 것이기 때문에 authorization_code 으로 써준다.
  • code 는 클라이언트로 부터 받은 authorization code 를 넣는다.

응답이 성공하면 아래와 같은 형식으로 값이 온다.

{
	"access_token": "adg61...670r9",
	"token_type": "Bearer",
	"expires_in": 3600,
	"refresh_token": "rca7...lABoQ",
	"id_token": "eyJra...96sZg"
}

이중 우리는 refresh token 이 필요하다. 왜냐하면 refresh token 은 만료일이 없고 사용자 토큰을 만료시킬 수 있기 때문이다.

다른 블로그들을 찾아 보았을때는 회원 탈퇴를 위해 회원 탈퇴 시점에서 클라이언트 측에서 다시 로그인을 하고 받은 authorization coderefresh token 을 재발급 한다고 많이 적혀있었다. 하지만 회원 탈퇴를 위해 로그인을 다시 하는 과정을 매우 비 효율적이라고 생각했다. 더 고민해보니까 회원가입 시점에서 refresh token을 발급 받고 저장 해두고 회원 탈퇴 시점에서 사용하면 효율적으로 로직을 처리 할 수 있을 것 같았다.

구현

public String getAppleRefreshToken(String authorizationCode) {
	MultiValueMap<String, String> body = getCreateTokenBody(authorizationCode);

	AppleTokenResponse appleTokenResponse = restClient.post()
		.uri("https://appleid.apple.com/auth/token")
		.contentType(MediaType.APPLICATION_FORM_URLENCODED)
		.body(body)
		.retrieve()
		.body(AppleTokenResponse.class);

	return Objects.requireNonNull(appleTokenResponse).getRefresh_token();
}

private MultiValueMap<String, String> getCreateTokenBody(String authorizationCode) {
	MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
	body.add("code", authorizationCode);
	body.add("client_id", clientId);
	body.add("client_secret", appleKeyGenerator.getClientSecret());
	body.add("grant_type", "authorization_code");
	return body;
}
public class AppleTokenResponse {
	private String access_token;
	private String expires_in;
	private String id_token;
	private String refresh_token;
	private String token_type;
	private String error;
}

RestClient 를 사용하여 구현했다. 단순히 RestClient 로 요청을 날리면 되는 코드라서 간단하다. RestClient 에 대한 레퍼런스가 많이 없어서 formData 를 만드는데 애 먹었다. LinkedMultiValueMap 를 사용해서 만들면 되었다.

저장하는 코드는 각자 사용하는 DB 에 따라서 저장하면 된다. 나의 경우 MySQL 에 apple_refresh_token 테이블을 만들어서 저장했다.

사용자 토큰을 해지하는 방법

client secretrefresh token 을 저장 했다면 토큰을 해지 하는 방법은 간단하다.

공식문서를 보면 위의 내용을 포함해서 api 요청을 하라고 나와있다. 이쯤 왔으면 어떤 값들을 넣어야될지 알 수 있을 것이다.

token_type_hint 에는 “refresh_token" 을 넣어준다.

구현

public void revokeToken(String refreshToken) {
	MultiValueMap<String, String> body = getRevokeTokenBody(refreshToken);

	restClient.post()
		.uri("https://appleid.apple.com/auth/revoke")
		.contentType(MediaType.APPLICATION_FORM_URLENCODED)
		.body(body)
		.retrieve()
		.toBodilessEntity();
}

private MultiValueMap<String, String> getRevokeTokenBody(String refreshToken) {
	MultiValueMap<String, String> body = new LinkedMultiValueMap<>();
	body.add("client_id", clientId);
	body.add("token", refreshToken);
	body.add("client_secret", appleKeyGenerator.getClientSecret());
	body.add("token_type_hint", "refresh_token");
	return body;
}

토큰을 얻을 때와 비슷하게 RestClient 를 사용해서 요청을 보내면 된다. 파기후 DB에 저장된 토큰도 삭제해주었다.

정상적으로 삭제가 되었다면 삭제된 계정에 메일이 온다.

또한 앱 로그인 화면에서도 새로 가입하는 화면으로 바뀐다.

마치며…

상당히 복잡한 과정이었다. 애플 로그인에 관한 레퍼런스도 블로그마다 전부 다르고 명확하게 알려주는 곳이 없어 결국 공식 문서에 많이 의존해서 구현했다. 그래도 이렇게 한번 제대로 해보았기 때문에 이후 프로젝트에서 애플 로그인을 구현할 일이 생기면 막힘 없이 구현할 수 있을 것 같다.

이렇게 탈퇴까지 구현해서 우리팀은 정상적으로 앱스토어에 배포를 성공 하였다. 만약 실제 배포 까지 생각이 없다면 회원 탈퇴를 시키는 과정을 구현하지 않아도 괜찮을 것 같다.

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

0개의 댓글