처음 애플 로그인/회원가입을 구현할 때에는 애플 문서를 하나하나 다 읽어보고 모든 단계를 다 공부하는 것도 좋지만(어려움🥹), 애플 로그인이 진행되는 과정만 이해하고 누군가가 구현해둔 코드를 따라서 작성해보는게 훨씬 수월하다는 생각이 들어 이 게시글을 작성하였다. (나도 누군가의 코드를 따라 구현을 하면서 애플 로그인을 이해했기에...) 참고로 나는 애플 서버를 통해 사용자 인증을 받고 난 뒤, 자체 토큰을 발급해주는 방식으로 구현을 하였다.
애플 로그인이 진행되는 과정을 먼저 살펴봅시다아
클라이언트가 수행하는 과정은 간략하게 설명하겠습니다.

이는 애플 공식 문서에 있는 그림이다. App인 클라이언트가 로그인 요청을 보내면, API(우리 서버 아님)는 사용자 정보를 요청하고, 사용자 인증을 진행한 후 Apple ID servers에서 토큰을 얻는다. 서버는 앱에 사용자의 이름과, 요청된 경우 이메일 주소도 제공한다. 이때 받게 되는 identity token을 우리 서버에 보내주어, 토큰 검증이 진행된다 !
더 자세히 알고 싶다면
애플 공식 문서
클라이언트가 유저의 정보(identity token 포함)를 받은 후, 서버는 해당 토큰을 검증하여 토큰이 만료되지 않았고, 변조되거나 앱에 재전송되지 않았음을 확인해주는 과정을 진행한다.

애플 로그인을 위해서는 애플 Developer 계정이 필요하다. 애플 디벨로퍼
계정 로그인을 하고 나서 인증서, ID 및 프로파일 > 식별자를 들어가준다. 
Certificates, Identifiers & Profiles에서 +버튼을 눌러주고
App IDs를 선택하고 continue를 눌러준다.
App을 선택해주고
Description이랑 Bundle ID를 작성해준다. Bundle ID는 앱을 구별하는 고유 식별자이다.
Sign In with Apple을 체크해준후 Edit를 눌러준다.
Enable as a primary App ID를 체크해주고, 아래 Server-to-Server Notification Endpoint는 나중에 채워주어도 상관없다. 이는 애플 사용자의 메일 전달 설정을 변경하거나, 앱 계정을 삭제하거나, Apple ID를 영구적으로 삭제할 때 필요한 endpoint로 위와 같은 기능을 구현해준다면 작성해주어야 한다.
작성을 완료하고 save를 누르면 이렇게 App ID Prefix가 뜨는데 이 Team ID는 잘 기억해두었다가 나중에 application.yml파일에 작성해주어야 한다.
서비스 아이디도 동일하게 identifiers에서 +버튼을 눌러 생성해준다.
Services IDs를 선택한 뒤
Discription과 Identifier를 지정하여 작성해준다. (Identifier는 domain을 반대로 뒤집은 string style을 추천한다고 되어있다.)
그럼 이렇게 Services IDs가 생성된것을 확인할 수 있고, 다시 이 아이디를 클릭해서 들어가준다.
Sign In with Apple에서 Configure를 클릭해준다.
Web Authentication Configuration에는 생성해준 App ID를 선택해주고, Website URLs도 적절히 작성해준다. Domains에는 도메인을 구입하였다면 구입한 도메인을 작성해주고(http://, https:// 와 같은 프로토콜은 제외하고 입력), 아직 구매하지 않았다면 아무렇게 적어주고 나중에 바꿔주면 된다. Return URLs(커스텀해서 설정하면 된다.)는 나중에 redirect-uri로 사용될 URL인데, 'https://' 프로토콜만 등록할 수 있다.
모든 것을 작성해주고 난 후, identifier를 잘 저장해두자. 이는 나중에 client_id, aud 값으로 사용된다.
Keys 화면으로 접속해준 뒤, +버튼을 눌러 새로운 키를 생성해준다.
Key Name 설정을 해주고, Sign In with Apple의 Configure를 눌러준다.
App ID 선택 후
생성을 해준다.
그럼 이렇게 Key를 다운로드 할 수 있는 화면이 나온다. 키는 한 번밖에 다운 못하니, 다운로드 후 잘 저장해두어야 한다. 또한 Key ID값도 잘 저장해두어, application.yml의 환경변수로 작성해주어야한다.
spring :
social-login:
provider:
apple:
redirect-uri: ${REDIRECT_URI} // Return URLs 값
client-id: ${APPLE_CLIENT_ID} // Services IDs의 identifier
key-id: ${APPLE_KEY_ID} // Key ID
team-id: ${APPLE_TEAM_ID} // App ID Prefix의 Team ID
audience: https://appleid.apple.com
private-key: ${APPLE_SECRET} //다운로드 받은 key .p8파일 내용, BEGIN END 어쩌구는 제외
애플 서버와의 HTTP 통신을 위해 FeignClient를 사용하였다. FeignClient에 대해서는 따로 더 자세히 다뤄봐야겠다.
implementation 'org.springframework.cloud:spring-cloud-starter-openfeign'
@Configuration
@EnableFeignClients(basePackageClasses = Application.class)
public class FeignClientConfig {}
@FeignClient(name = "appleClient", url = "https://appleid.apple.com/auth/keys")
public interface AppleAuthClient {
@GetMapping
ApplePublicKeyResponse getAppleAuthPublicKey();
}
https://appleid.apple.com/auth/keys 는 kid와 alg 정보에 맞는 Apple의 공개 키를 제공하는 URL이다.
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class ApplePublicKey {
private String kty;
private String kid;
private String use;
private String alg;
private String n;
private String e;
}
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Getter
public class ApplePublicKeyResponse {
private List<ApplePublicKey> keys;
// 전달받은 kid와 alg를 기준으로 keys 목록에서 일치하는 ApplePublicKey를 찾아 반환
public ApplePublicKey getMatchedKeyBy(String kid, String alg) {
return keys.stream()
.filter(key -> key.getKid().equals(kid) && key.getAlg().equals(alg))
.findAny()
.orElseThrow(() -> new ExceptionHandler(ErrorStatus.INVALID_APPLE_ID_TOKEN_INFO));
}
}
Apple 공개 키를 생성하고 검증하는 클래스
@Component
@RequiredArgsConstructor
@Slf4j
public class ApplePublicKeyGenerator {
// tokenHeaders에서 kid와 alg 값을 추출하고, 일치하는 공개 키 조회
public PublicKey generatePublicKey(Map<String, String> tokenHeaders,
ApplePublicKeyResponse applePublicKeys) {
ApplePublicKey publicKey = applePublicKeys.getMatchedKeyBy(tokenHeaders.get("kid"),
tokenHeaders.get("alg"));
return getPublicKey(publicKey);
}
// ApplePublicKey의 n과 e 값을 사용하여 RSA 공개 키를 생성하고, 이를 서명 검증에 사용
private PublicKey getPublicKey(ApplePublicKey publicKey) {
byte[] nBytes = Base64.getUrlDecoder().decode(publicKey.getN());
byte[] eBytes = Base64.getUrlDecoder().decode(publicKey.getE());
RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(new BigInteger(1, nBytes),
new BigInteger(1, eBytes));
try {
KeyFactory keyFactory = KeyFactory.getInstance(publicKey.getKty());
return keyFactory.generatePublic(publicKeySpec);
} catch (NoSuchAlgorithmException | InvalidKeySpecException exception) {
throw new IllegalStateException("Apple OAuth 로그인 중 public key 생성에 문제가 발생했습니다.");
}
}
}
나는 애플로그인 구현 시 jwt도 함께 사용했기 때문에, 아래 게시글에서 다뤘던 JwtService.java에 애플 로그인과 관련된 코드를 추가해주었다. JwtService.java 파일에 대해 더 자세히 보고 싶다면 아래 링크를 통해 보면 된다.
https://velog.io/@pdy0207/spring-3
@Slf4j
@RequiredArgsConstructor
@Component
@Service
public class JwtService {
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(Base64.getUrlDecoder().decode(encodedHeader));
return objectMapper.readValue(decodedHeader, Map.class);
} catch (JsonMappingException e) {
throw new RuntimeException("apple token 값이 jwt 형식인지, 값이 정상적인지 확인해주세요.");
} catch (JsonProcessingException e) {
throw new ExceptionHandler(ErrorStatus.INVALID_APPLE_ID_TOKEN);
}
}
public Claims getTokenClaims(final String token, final PublicKey publicKey) {
try {
return Jwts.parser()
.setSigningKey(publicKey)
.parseClaimsJws(token)
.getBody();
} catch (UnsupportedJwtException | MalformedJwtException | SignatureException | IllegalArgumentException e) {
throw new ExceptionHandler(ErrorStatus.INVALID_APPLE_ID_TOKEN);
}
}
}
나는 이곳에 애플 로그인/회원가입 서비스를 구현하였다.
@Slf4j
@Service
@RequiredArgsConstructor
public class OAuthService {
@Autowired
private final AppleAuthClient appleAuthClient;
private final ApplePublicKeyGenerator applePublicKeyGenerator;
private final JwtService jwtService;
private final MemberRepository memberRepository;
//accessToken, refreshToken 생성
@Transactional
public LoginResponseDto createToken(Member member) {
String newAccessToken = jwtService.generateAccessToken(member.getMemberId());
String newRefreshToken = jwtService.generateRefreshToken(member.getMemberId());
System.out.println("newAccessToken : " + newAccessToken);
System.out.println("newRefreshToken : " + newRefreshToken);
// DB에 refreshToken 저장
member.updateRefreshToken(newRefreshToken);
memberRepository.save(member);
System.out.println("member nickname : " + member.getUsername());
return new LoginResponseDto(newAccessToken, newRefreshToken);
}
//애플 로그인
@Transactional
public LoginResponseDto appleLogin(AppleLoginRequestDto appleLoginRequestDto) {
log.info("Current time is {}", LocalDateTime.now());
// 1. 애플 공개 키 가져오기
Map<String, String> headers = jwtService.parseHeader(appleLoginRequestDto.getIdentityToken());
PublicKey publicKey = applePublicKeyGenerator.generatePublicKey(headers, appleAuthClient.getAppleAuthPublicKey());
// 2. JWT 검증 및 클레임 추출
Claims claims = jwtService.getTokenClaims(appleLoginRequestDto.getIdentityToken(), publicKey);
// 3. 클레임에서 subject 추출
String sub = claims.getSubject();
// 4. 유저가 등록되어 있는지 확인
Member member = memberRepository.findByAppleSub(sub)
.orElse(null);
if (member == null) {
// 등록된 유저가 아닌 경우 회원가입 로직
throw new ExceptionHandler(MEMBER_NOT_REGISTERED);
}
// 5. 토큰 생성 및 반환
return createToken(member);
}
// 애플 회원가입
@Transactional
public LoginResponseDto appleSignUp(AppleSignUpRequestDto appleSignUpRequestDto) {
Map<String, String> headers = jwtService.parseHeader(appleSignUpRequestDto.getIdentityToken());
PublicKey publicKey = applePublicKeyGenerator.generatePublicKey(headers, appleAuthClient.getAppleAuthPublicKey());
Claims claims = jwtService.getTokenClaims(appleSignUpRequestDto.getIdentityToken(), publicKey);
String email = claims.get("email", String.class);
String sub = claims.getSubject();
Member member = memberRepository.findByAppleSub(sub).orElse(null);
if (member == null) {
member = memberRepository.save(
Member.builder()
.email(email)
.username(appleSignUpRequestDto.getUserName()) // appleLoginRequestDto에서 닉네임 가져오기
.appleSub(sub)
.loginType(LoginType.APPLE)
.refreshToken("") // 초기 빈 값 설정
.refreshTokenExpiresAt(LocalDateTime.now()) // 초기 시간 설정
.build()
);
}
// 5. 토큰 생성 및 반환
return createToken(member);
}
}
내가 진행했던 프로젝트는 회원가입 화면이 따로 존재해서 저렇게 로그인과 회원가입을 분리하여 작성해주었다.
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
@Validated
public class OAuthController {
private final OAuthService oAuthService;
@Operation(summary = "애플 로그인 API")
@PostMapping("/apple/login")
public ApiResponse<LoginResponseDto> appleLogin(@RequestBody @Validated AppleLoginRequestDto appleReqDto) {
if (appleReqDto.getIdentityToken() == null)
throw new ExceptionHandler(APPLE_ID_TOKEN_EMPTY);
return ApiResponse.onSuccess(oAuthService.appleLogin(appleReqDto));
}
@Operation(summary = "애플 회원가입 API")
@PostMapping("/apple/signup")
public ApiResponse<LoginResponseDto> appleSignUp(@RequestBody @Validated AppleSignUpRequestDto appleSignUpReqDto) {
if (appleSignUpReqDto.getIdentityToken() == null)
throw new ExceptionHandler(APPLE_ID_TOKEN_EMPTY);
return ApiResponse.onSuccess(oAuthService.appleSignUp(appleSignUpReqDto));
}
}
이렇게 하면 애플 로그인 구현 끝 !! 일단 코드를 따라 작성하고 테스트까지 해본 후, 다시 여러 레퍼런스들을 읽어보면 이해하기가 훨씬 수월할 것이다 !! 사실 claims을 검증하는 과정도 필요하고, 토큰을 자체적으로 생성하지 않고 애플 서버가 제공하는 refreshToken을 이용하는 방법도 있다. 이에 대해서는 더 공부를 해보아야 할 것 같다. 우리 모두 🥧(파이)팅 !!
[Reference]
https://velog.io/@komment/Spring-Boot-OAuth-2.0-JWT를-활용한-소셜-로그인-구현-2-애플편
https://velog.io/@haron/Spring-애플-로그인을-구현해보자
https://kth990303.tistory.com/436
https://kth990303.tistory.com/437