우당탕탕 개발일지

[방치RPG 서버 제작기] 12. Apple 계정연동하기 본문

Server/방치RPG 서버

[방치RPG 서버 제작기] 12. Apple 계정연동하기

devchop 2025. 5. 31. 11:06

클라이언트 

 

SDK 임포트 - unitypackage로 되어있어서 편하게 임포트 가능하다. 이 sdk는 안드로이드 상에서는 작동하지 않으므로, 관련된 코드는 모두 #if UNITY_IOS 로 감싸주는것을 잊지말자.

 

https://github.com/lupidan/apple-signin-unity

 

GitHub - lupidan/apple-signin-unity: Unity plugin to support Sign In With Apple Id

Unity plugin to support Sign In With Apple Id. Contribute to lupidan/apple-signin-unity development by creating an account on GitHub.

github.com

 

 

info.plist 에 다음 내용을 추가해야한다. 그런데 YourUnityProject/Build/iOS/Info.plist  파일은 빌드할때마다 초기회되는 문제가있다. 그러므로, 빌드할때마다 자동으로 아래 내용을 넣어주도록, 스크립트로 제작한다. Assets/Editor/IOSPostProcess.cs 를 추가한다.

<key>CFBundleURLTypes</key>
<array>
  <dict>
    <key>CFBundleURLSchemes</key>
    <array>
      <string>your.bundle.id.here</string>
    </array>
  </dict>
</array>

<key>NSUserAuthenticationUsageDescription</key>
<string>로그인을 위해 Apple ID 인증이 필요합니다.</string>

 

 

#if UNITY_IOS
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;
using System.IO;

public static class IOSPostProcess
{
    [PostProcessBuild]
    public static void OnPostProcessBuild(BuildTarget target, string pathToBuiltProject)
    {
        if (target != BuildTarget.iOS) return;

        string plistPath = Path.Combine(pathToBuiltProject, "Info.plist");
        PlistDocument plist = new PlistDocument();
        plist.ReadFromFile(plistPath);

        PlistElementDict rootDict = plist.root;

        // 사용 목적 설명 (권한)
        rootDict.SetString("NSUserAuthenticationUsageDescription", "로그인을 위해 Apple ID 인증이 필요합니다.");

        // 저장
        File.WriteAllText(plistPath, plist.WriteToString());
    }
}
#endif

 

 

클라이언트 구현부 // 이런식으로 구현한다.

#if UNITY_IOS
using UnityEngine;
using AppleAuth;
using AppleAuth.Enums;
using AppleAuth.Interfaces;
using AppleAuth.Native;
using System.Text;
public class IOSLoginManager : MonoBehaviour
{

    public void SignInWithApple()
    {
        if (AppleAuthManager.IsCurrentPlatformSupported)
        {
            var loginArgs = new AppleAuthLoginArgs(LoginOptions.IncludeEmail | LoginOptions.IncludeFullName);
            var appleAuthManager = new AppleAuthManager(new PayloadDeserializer());

            appleAuthManager.LoginWithAppleId(
                loginArgs,
                credential =>
                {
                    var appleIdCredential = credential as IAppleIDCredential;
                    string userId = appleIdCredential.User;
                    string token = Encoding.UTF8.GetString(appleIdCredential.IdentityToken);
                    // 이 토큰을 서버로 보내서 검증하면 됨
                },
                error =>
                {
                    Debug.LogError("Apple 로그인 실패: " + error);
                });
        }
    }
}
#endif

 

 

서버

의존성 추가하기

// build.gradle
implementation 'com.nimbusds:nimbus-jose-jwt:9.37.3'

 

서버쪽에서 토큰을 인증하는 작업이 필요하다. 이를 위해서, 또 com.compay..// 와 같은 client-id가 필요하다.

하드코딩 하기보다는, Application.yml파일에 다음과 같이 정의해놓고 ,AplleAuthService.java에서 사용하도록 하자. 

apple:
  client-id: com.my.company

사용할땐 다음처럼 가져온다.

@Value("${apple.client-id}")
    private String appleClientId;

 

 

AppleAuthService.java

@Service
@RequiredArgsConstructor
public class AppleAuthService {

    @Value("${apple.client-id}")
    private String appleClientId;

    private final UserRepository userRepository;

    // 🔐 캐싱 관련 필드
    private JWKSet cachedKeySet;
    private Instant lastFetchTime;
    private static final Duration CACHE_TTL = Duration.ofHours(24);



    public AppleUserInfo verifyAppleToken(String identityToken) {
        try {
            System.out.println("verify Apple token called. token: "+identityToken);
            SignedJWT jwt = SignedJWT.parse(identityToken);
            JWKSet keySet = getKeySet();
            JWK key = keySet.getKeyByKeyId(jwt.getHeader().getKeyID());

            if (key == null) throw new RuntimeException("No matching Apple key found");

            JWSVerifier verifier = new RSASSAVerifier(((RSAKey) key).toRSAPublicKey());
            if (!jwt.verify(verifier)) throw new RuntimeException("JWT signature invalid");

            JWTClaimsSet claims = jwt.getJWTClaimsSet();
            if (!"https://appleid.apple.com".equals(claims.getIssuer()))
                throw new RuntimeException("Invalid issuer");
            if (!claims.getAudience().contains(appleClientId))
                throw new RuntimeException("Invalid audience");

            String sub = claims.getSubject();
            String email = claims.getStringClaim("email");

            return new AppleUserInfo(sub, email);
        } catch (Exception e) {
            System.out.println("apple token verification failed. "+e.getMessage());
            throw new RuntimeException("Apple token verification failed", e);
        }
    }

    // 🔄 키셋 가져오기 (캐싱 적용)
    private JWKSet getKeySet() throws Exception {
        if (cachedKeySet == null || lastFetchTime == null || Instant.now().isAfter(lastFetchTime.plus(CACHE_TTL))) {
            try (InputStream is = new URI("https://appleid.apple.com/auth/keys").toURL().openStream()) {
                cachedKeySet = JWKSet.load(is);
                lastFetchTime = Instant.now();
                System.out.println("Apple 공개키셋 새로 로딩됨");
            }
        }
        return cachedKeySet;
    }

    public ApiResponse<Void> linkApple(String uid, String identityToken){
        AppleUserInfo info = verifyAppleToken(identityToken);
        var user = userRepository.findByUid(uid).orElseThrow(()-> new RuntimeException("user not found"));

        if(!user.getPlatform().equalsIgnoreCase("GUEST")){
            return ApiResponse.failure("이미 연동된 계정입니다. ("+user.getPlatform()+")");
        }
        if(userRepository.existsByAppleId(info.getSub())){
            return ApiResponse.failure("이미 사용 중인 Apple 계정입니다.");
        }

        user.setPlatform("APPLE");
        user.setAppleId(info.getSub());
        userRepository.save(user);
        return ApiResponse.success(null);


    }


}

 

클라이언트에서 apple 로그인을 해서 토큰을 받아오면 => 서버에서 검증을 해서 subject를 가져온다. db에는 User의 appleId란에 이 subject를 저장해야한다.