본문 바로가기

SpringBoot

[Spring][Apple Login][OAuth2] SpringBoot로 Apple Login 구현

728x90
반응형

[1] 스프링 프로젝트에 애플 로그인 API 연동을 위한 Apple Developer 설정

  • 먼저 Apple Developer 계정 설정을 해야한다. 설정은 아래 사이트를 보고 한다.

[1] 스프링 프로젝트에 애플 로그인 API 연동을 위한 Apple Developer 설정

[2] 스프링 프로젝트에 애플 로그인 API연동하기

프로젝트 스펙

  • IntelliJ
  • SpringBoot
  • Gradle
  • Java 17 버전
  • AWS EC2 서버: Ubuntu 20.04

로직

로그인 과정 및 개념은 아래 블로그를 보고 공부했다.

  • 애플 공식 문서:

Apple Developer Documentation

Spring API서버에서 Apple 인증(로그인 , 회원가입) 처리하기

 

Spring API서버에서 Apple 인증(로그인 , 회원가입) 처리하기

들어가며 사이드 프로젝트를 진행하던 도중 APP에서 Apple 로그인을 적용해야 했다. https://apps.apple.com/kr/app/%EA%B8%80%EC%9D%84%EB%8B%B4%EB%8B%A4/id1517289762 ‎글을담다 ‎마음 속 와 닿은 글을 손쉽게 담는, '

hwannny.tistory.com

 

  • 기존 Google, 카카오 OAuth2 처럼 한번에 로그인 할 수 있는 구조가 아니고 보안때문인디 한번 더 암호화 과정을 거쳐야 한다. 다른 소셜 로그인과 용어들도 조금은 달라서 처음에 생소하게 느껴졌다.
  • 간단하게 그린 구조도는

구현 과정

01.깃허브 코드 다운

02.application.properties 설정

  • Apple Developer 설정하면서 얻은 정보들로 설정해준다.
logging.level.com.whitepaek.demosigninwithapple=DEBUG

APPLE.AUTH.TOKEN.URL=https://appleid.apple.com/auth/token
APPLE.PUBLICKEY.URL=https://appleid.apple.com/auth/keys
# redirect url 정보
APPLE.WEBSITE.URL=[redirect url 정보]
APPLE.ISS=https://appleid.apple.com
# client_ID(Identifier 값)
APPLE.AUD=[client_ID]
# Team_ID
APPLE.TEAM.ID=[Team_ID]
# key_id
APPLE.KEY.ID=[key_id]
# key id path : AuthKey_[key_id], 애플 사이트 에서 다운 받아야 함,Apple Developer 설정하는 단계에서 받은 Private Key 파일이름
APPLE.KEY.PATH=static/AuthKey_[key_id].p8

03.애플 로그인 버튼 페이지

  • 프로젝트를 실행 후 "localhost:8080/"으로 접속하면 Sign in with Apple  로그인 화면이 나온다. 애플 공식 사이트에 있는 코드인 것 같다.
  • application.properties에 설정이 정상적이라면 로그인을 진행하고 값을 반환받을 수 있다.

[[로그인 버튼 페이지 컨트롤러 부분]]

  • 유저가 버튼을 클릭하면 로그인이 진행되는데 이때 메타정보와 유저 아이디, 비밀번호가 애플에게 요청된다.
// AppleController.java - 30 라인

@GetMapping(value = "/")
public String appleLoginPage(ModelMap model) {

  Map<String, String> metaInfo = appleService.getLoginMetaInfo();

  model.addAttribute("client_id", metaInfo.get("CLIENT_ID"));
  model.addAttribute("redirect_uri", metaInfo.get("REDIRECT_URI"));
  model.addAttribute("nonce", metaInfo.get("NONCE"));

  return "index";
}

필드 설명

ID 유저 아이디
Password 유저 비밀번호
appleid-signin-client-id Services ID - Identifier 값
appleid-signin-scope 애플에게 전달받을 유저 정보 - name email
appleid-signin-redirect-uri Services ID - Return URLs 값
appleid-signin-state 상태 값
appleid-signin-nonce 임시 값

그런데 실행 중 에러가 발생했다.

  • 🔥트러블 슈팅: [SpringBoot][Apple Login][PKCS8Key][java.sun.security] 애플 로그인에 필요한 PKCS8Key 라이브러리를 임포트하지 못해서 에러 발생 정확히는 java.sun.security를 export하지 못했다., ERROR 246122 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed; nested exception is java.lang.IllegalAccessError: superclass access check failed: class com.example.demo.util.ECPrivateKeyImpl2 (in unnamed module @0x2752f6e2) cannot access class sun.security.pkcs.PKCS8Key (in module java.base) because module java.base does not export sun.security.pkcs to unnamed module @0x2752f6e2] with root cause
    • ERROR 246122 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed; nested exception is java.lang.IllegalAccessError: superclass access check failed: class com.example.demo.util.ECPrivateKeyImpl2 (in unnamed module @0x2752f6e2) cannot access class sun.security.pkcs.PKCS8Key (in module java.base) because module java.base does not export sun.security.pkcs to unnamed module @0x2752f6e2] with root cause
    [에러 메시지2]
    • ERROR 246122 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Handler dispatch failed; nested exception is java.lang.IllegalAccessError: superclass access check failed: class com.example.demo.util.ECPrivateKeyImpl2 (in unnamed module @0x2752f6e2) cannot access class sun.security.pkcs.PKCS8Key (in module java.base) because module java.base does not export sun.security.pkcs to unnamed module @0x2752f6e2] with root cause java.lang.IllegalAccessError: superclass access check failed: class com.example.demo.util.ECPrivateKeyImpl2 (in unnamed module @0x2752f6e2) cannot access class sun.security.pkcs.PKCS8Key (in module java.base) because module java.base does not export sun.security.pkcs to unnamed module @0x2752f6e2
    [에러 메시지3]
    • gradle error: package sun.security.pkcs is not visible
    [ 문제 원인 ]
    • 코드 중 ****애플 로그인에 필요한 PKCS8Key 라이브러리를 export하지 못해서 에러 발생했다. 서칭해보니 java 17 버전부터는 지원을 하지 않아서 전 버전을 사용되어야 한다는 내용을 찾았다.
    • 그래서 java8, 11, 13 ,16등으로 시도했지만 실패. 정말 구글링해서 나오는 자료들을 거의 다 찾아 보다가 4주째에 새로 올라온 해결 방법을 찾았다.
    [ 해결 방안 1] : 해결
    • 같은 공개키 알고리즘의 라이브러리를 대체 사용
    • 기존 ECPrivateKeyImpl 클래스 대신→ PKCS8EncodedKeySpec 을 사용.
                PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(readPrivateKey());
    
                try{
                    KeyFactory kf = KeyFactory.getInstance("EC");
                    ECPrivateKey ecPrivateKey = (ECPrivateKey) kf.generatePrivate(spec);
                    JWSSigner jwsSigner = new ECDSASigner(ecPrivateKey.getS());
                    jwt.sign(jwsSigner);
                }catch (NoSuchAlgorithmException e) {
                    e.printStackTrace();
                }catch (InvalidKeySpecException e){
                    e.printStackTrace();
                }
    
    • 그리고 build.gradle에 export 문구 추가 함( 이 문구로 해결되었는지는 불명확해서 테스트 해봐야 함)
    tasks.withType(JavaCompile){
    	options.compilerArgs.addAll([
    			"--add-exports=java.base/sun.security.pkcs=ALL-UNNAMED",
    			"--add-exports=java.base/sun.security.util=ALL-UNNAMED",
    			"--add-exports=java.base/sun.security.x509=ALL-UNNAMED"
    	])
    }
    
    [ 참고 자료 ]
  •  
  • 🔥트러블 슈팅: [SpringBoot][Apple Login] PKCS8Key관련 에러를 해결 후 p8 파일을 read하는 과정에서 에러가 발생., ERROR 349654 --- [nio-8080-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.NullPointerException] with root causejava.lang.NullPointerException: null at java.base/java.io.FileInputStream.<init>(FileInputStream.java:149) ~[na:na] at java.base/java.io.FileInputStream.<init>(FileInputStream.java:111) ~[na:na] at java.base/java.io.FileReader.<init>(FileReader.java:60) ~[na:na] at com.example.demo.util.AppleUtils.readPrivateKey(AppleUtils.java:187) ~[classes!/:na]
  • [에러 메시지1] 
    • ERROR 349654 --- [nio-8080-exec-3] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.NullPointerException] with root causejava.lang.NullPointerException: null at java.base/java.io.FileInputStream.<init>(FileInputStream.java:149) ~[na:na] at java.base/java.io.FileInputStream.<init>(FileInputStream.java:111) ~[na:na] at java.base/java.io.FileReader.<init>(FileReader.java:60) ~[na:na] at com.example.demo.util.AppleUtils.readPrivateKey(AppleUtils.java:187) ~[classes!/:na]
    [에러메시지2]
    • ERROR 349878 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet]    : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.bouncycastle.util.encoders.DecoderException: unable to decode base64 string: invalid characters encountered in base64 data] with root causejava.io.IOException: invalid characters encountered in base64 data at org.bouncycastle.util.encoders.Base64Encoder.decode(Unknown Source) ~[bcprov-jdk15on-1.52.jar!/:1.52.0] at org.bouncycastle.util.encoders.Base64.decode(Unknown Source) ~[bcprov-jdk15on-1.52.jar!/:1.52.0] at org.bouncycastle.util.io.pem.PemReader.loadObject(Unknown Source) ~[bcprov-jdk15on-1.52.jar!/:1.52.0] at org.bouncycastle.util.io.pem.PemReader.readPemObject(Unknown Source) ~[bcprov-jdk15on-1.52.jar!/:1.52.0] at com.example.demo.util.AppleUtils.readPrivateKey(AppleUtils.java:194) ~[classes!/:na]
    [ 문제 원인 ]
    • 에러를 살펴보니 privateKey파일을 read 하는 과정중에 에러가 발생했다.
    • 기존 깃헙 코드에 AuthKey_[Key_id].p8 파일이 있는데 개인 AppleDeveloper 파일로 커스터 마이징하지 않고 그대로 사용했다.
    [ 해결 방안 1] : 해결
    • 이 파일은 application.properties 에서 말했듯 Apple Developer설정 과정 중 다운받을 수 있다. 다운받은 파일의 이름으로 application.properties의 APPLE.KEY.PATH 도 적용해줘야 한다.
    [AuthKey_[Key_id].p8]
-----BEGIN PRIVATE KEY-----
[이부분에 Apple에서 발급받은 정보가 들어있을 것이다.]
-----END PRIVATE KEY-----
  • [ 참고 자료 ]
  •  
  • 🔥트러블 슈팅: [SpringBoot][Apple Login][Payload] Payload를 생성하면서 decode하는 과정에서 에러가 발생, ERROR 351412 --- [nio-8080-exec-6] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.NullPointerException: Cannot invoke "com.nimbusds.jose.Payload.toString()" because the return value of "com.example.demo.util.AppleUtils.decodeFromIdToken(String)" is null] with root cause
  • [문제 원인] 
    • p8문제를 해결하고 실행 해보니 Payload를 생성하면서 decode하는 과정에서 에러가 발생했다.
    • 서버에서 클라로 전송할 때 TokenResponse 필드값에 null값이 들어있어서 에러가 발생한 것으로 보인다. 그래서 null값 허용하는 것을 찾아보았다.
    [ 해결 방안 1] : 해결
    • 이는 구글링을 통해 해결했는데 3가지 방법이 있지만 필자는 TokenResponse 클래스에 @JsonIgnoreProperties(ignoreUnknown =true) 어노테이션을 추가해서 해결했다.
    (TokenRespons ) 클래스
@JsonIgnoreProperties(ignoreUnknown=true)
public class TokenResponse {
...
}

 

  • [ 참고 자료 ]
  • 🔥트러블 슈팅: [SpringBoot][Apple Login][The code has already been used] 애플 로그인 인증하는 과정 중 인증이 이미 되었다는 에러 발생, ERROR 353636 --- [nio-8080-exec-5] com.example.demo.util.HttpClientUtils : [doPost]post url(https://appleid.apple.com/auth/token) failed. status code:400. reason:. param:{"code":”[개인정보]","grant_type":"[개인정보]","client_secret":"[개인정보]","redirect_uri":"[개인정보]"}. result:{"error":"invalid_grant","error_description":"The code has already been used."}
  • [문제 원인] 
    • 모든 문제를 해결하고 실행하는데 The code has already been used 에러라면서 애플로그인에 사용된 개인 정보들이 이미 사용되었다는 에러가 발생했다.
    • 원인을 구글링 해보니 공식 사이트에 애플 로그인을 할 때 제한사항이 있었다.
      1.애플 로그인 인증을 하고 authorization grant code는 5분 이내에 해야한다.
      2.횟수가 처음 1번으로 제한
    [ 해결 방안 1] : 해결
    • 알고보니 필자는 인증하는 메소드를 연속 2번 중복 호출해서 에러가 발생했던 것이었다. 그래서 인증요청을 1번만 하니 해결되었다. 트러블 슈팅하는데 사소한 에러에 많은 시간을 썼다 ㅜㅜ.
    [ 참고 자료 ]

04.public Key 요청

Public 키를 요청한다. 요청하면 3개의 키가 온다.

Apple Developer Documentation

{
    "keys": [
        {
            "kty": "RSA",
            "kid": "fh6Bs8C",
            "use": "sig",
            "alg": "RS256",
            "n": "u704gotMSZc6CSSVNCZ1d0S9dZKwO2BVzfdTKYz8wSNm7R_KIufOQf3ru7Pph1FjW6gQ8zgvhnv4IebkGWsZJlodduTC7c0sRb5PZpEyM6PtO8FPHowaracJJsK1f6_rSLstLdWbSDXeSq7vBvDu3Q31RaoV_0YlEzQwPsbCvD45oVy5Vo5oBePUm4cqi6T3cZ-10gr9QJCVwvx7KiQsttp0kUkHM94PlxbG_HAWlEZjvAlxfEDc-_xZQwC6fVjfazs3j1b2DZWsGmBRdx1snO75nM7hpyRRQB4jVejW9TuZDtPtsNadXTr9I5NjxPdIYMORj9XKEh44Z73yfv0gtw",
            "e": "AQAB"
        },
        {
            "kty": "RSA",
            "kid": "YuyXoY",
            "use": "sig",
            "alg": "RS256",
            "n": "1JiU4l3YCeT4o0gVmxGTEK1IXR-Ghdg5Bzka12tzmtdCxU00ChH66aV-4HRBjF1t95IsaeHeDFRgmF0lJbTDTqa6_VZo2hc0zTiUAsGLacN6slePvDcR1IMucQGtPP5tGhIbU-HKabsKOFdD4VQ5PCXifjpN9R-1qOR571BxCAl4u1kUUIePAAJcBcqGRFSI_I1j_jbN3gflK_8ZNmgnPrXA0kZXzj1I7ZHgekGbZoxmDrzYm2zmja1MsE5A_JX7itBYnlR41LOtvLRCNtw7K3EFlbfB6hkPL-Swk5XNGbWZdTROmaTNzJhV-lWT0gGm6V1qWAK2qOZoIDa_3Ud0Gw",
            "e": "AQAB"
        },
        {
            "kty": "RSA",
            "kid": "W6WcOKB",
            "use": "sig",
            "alg": "RS256",
            "n": "2Zc5d0-zkZ5AKmtYTvxHc3vRc41YfbklflxG9SWsg5qXUxvfgpktGAcxXLFAd9Uglzow9ezvmTGce5d3DhAYKwHAEPT9hbaMDj7DfmEwuNO8UahfnBkBXsCoUaL3QITF5_DAPsZroTqs7tkQQZ7qPkQXCSu2aosgOJmaoKQgwcOdjD0D49ne2B_dkxBcNCcJT9pTSWJ8NfGycjWAQsvC8CGstH8oKwhC5raDcc2IGXMOQC7Qr75d6J5Q24CePHj_JD7zjbwYy9KNH8wyr829eO_G4OEUW50FAN6HKtvjhJIguMl_1BLZ93z2KJyxExiNTZBUBQbbgCNBfzTv7JrxMw",
            "e": "AQAB"
        }
    ]
}

05.Identity Token 디코딩

  • 응답받은 Identity Token을 jwt디코딩을 하면 헤더와 PayLoad에 여러 정보를 볼 수 있다.
  • public 키 중에서 Identity Token을 디코딩한 header 값(kid, alg)과 일치하는 키를 사용한다.
  • jwt 디코딩 사이트: https://jwt.io/

보안상 부분 캡쳐를 했다.

06.n, e 값을 통해 public key를 생성한뒤 public key로 Identity Token의 서명(signature)을 검증한다.

[내부 알고리즘]

  • Identity Token을 조회하고
  • client_secret 을 생성한다.
  • private 키를 생성한다. authorization_code도 jwt에 담아서 서명 검증 요청 →

[데이터 정보]

1)Apple developer account info

코드로 주는게 아니라 이렇게 따로 알려줘야함

  1. 2)PK
  • User Identifier: String = (유저 고유 ID)
  • Email (프론트 로그인시 한번만 제공 & 앱 삭제 후 로그인 해도 못받음)
    • 프론트에서 KeyChain (암호화 된 데이터베이스)에 저장시 앱삭제해도 가져올수 있다.

07.다시 로그인 시도

  • 주의 점: 사진에는 로컬 호스트로 접속했지만 실제 로그인 접속은 배포 도메인 주소로 접속해야 한다. 왜냐하면 apple redirect 주소가 https 여야 한다. 그래서 필자는 AWS EC2 에서 실행한 다음 로그인 테스트를 진행했다.
  • 로그인 버튼을 누르면 로그인 페이지로 연결된다. 거기서 자신의 Apple 아이디로 로그인을 시도하면 된다.

 

08.로그인 response값

{
"access_token":"[개인정보]",
"token_type":"[개인정보]",
"expires_in":[개인정보],
"refresh_token":"[개인정보]",
"id_token":"[개인정보]"
}

 

refresh 토큰 검증 테스트

  • 발급받은 사용자의 refresh 토큰을 검증 할 수 있다.

  • 만약 토큰이 만료 후이면 새로운 refresh_token 발급
  • 토큰 만료 전이며 기존 id_token만 반환되고 refresh_token은 null로 반환

 

 

소감

  • 애플 공식 사이트에 자바 예제 코드도 없고 구글링에서 자료도 없어서 구현하는데 애를 많이 먹었다. 다른 프로젝트와 병행하다보니 4주 간의 시간에 걸쳐 구현을 하게 되었다. 자료도 없고 에러에 대한 정보도 거의 없어서 필자가 불편했기에 다른 사람들은 이 글을 보고 애플 로그인을 구현하는데에 도움이 되고 싶다.
  • 덕분에 애플의 소셜 로그인의 내부 흐름과 로직에 대해 많이 알게 되었다.

 

[Reference]

[그 외 도움이 될 만한 사이트]

반응형