Roen의 iOS 개발로그

[SwiftUI]Sign in with Apple 구현하기(1) : SignInWithAppleButton 파헤치기

by Steady On

시작

- iOS 앱을 만들 때 소셜 로그인을 구현하려면 필히 Apple 계정으로 로그인도 구현되어야 한다.

4.8 Apple로 로그인

앱에서 사용자의 기본 계정을 설정 또는 인증하기 위해 타사 또는 소셜 로그인 서비스(Facebook 로그인, Google 로그인, Twitter로 로그인, LinkedIn으로 로그인, Amazon으로 로그인 또는 WeChat 로그인 등)를 사용하는 앱은 Apple로 로그인 역시 동등한 옵션으로 제공해야 합니다. 사용자의 기본 계정은 사용자 식별, 로그인, 앱의 기능 및 연결된 서비스에 접근하기 위한 목적으로 앱에 설정한 계정을 의미합니다.

by. App Store 심사 지침(https://developer.apple.com/kr/app-store/review/guidelines/ )

 

준비

1. Apple Developer 멤버십을 결제하고 승인받아야 함

2. 해당 개발자 계정에 앱을 등록해서 올려야 함

3. 앱을 등록했으면 [Certificates, Identifiers & Profiles] - [Identifiers]에서 등록한 app을 선택하고 [Sign in with Apple]을 활성화 해줘야 한다.

4. Provisioning 파일을 따로 등록했었나? (나는 팀 리더의 파일을 받아서 진행했기 때문에 직접할때는 어떤지 모르겠다.)

5. 암튼 Project - targets 에서 +Capability를 눌러서 Sign in with Apple을 추가한다.

이러면 일단 준비는 끝이다.

 

View에서 Sign in with Apple 버튼 만들기

회원가입을 하고 로그인을 하는 것은 간단하다! 예전에는 이게 지원이 안되어서 UIKit에서 끌어다 쓴것 같지만, 이제는 매우매우 간단해졌다. 일단 View에서 Button부터 만들어보자. 원래 Apple 로그인 버튼을 만들기 위해서는 HIG의 까다로운 조건들을 지켜야한다. 로고의 사이즈는 어떻고, 버튼에 들어가는 문구 등등..(HIG;Sign in with Apple) 하지만 애플에서 제공해주는 컴포넌트를 쓴다면 딱히 그런건 신경쓰지 않아도 된다.

 

import AuthenticationServices

struct SignInView: View {
	var body: some View {
    	SignInWithAppleButton { request in
        	// Apple REST API에 요청보내기
        } onCompletion { result in 
        	// 받은 응답 처리하기
        }
    }
}

이렇게 해주면 아주 훌륭한 버튼이 생긴다. 버튼은 frame 수정자로 사이즈를 잡아 줄 수도 있고, cornerRadius로 둥글게 깎아줄 수도 있다.

init파라미터 는 label, onRequest, onCompletion 3개가 있다.

SignInWithAppleButton의 파라미터

label

 label은 기본 값이 .signIn이고 인자에 따라 라벨 값은 아래와 같이 달라진다.

.signIn Sign in with Apple
.signUp Sign up with Apple
.continue Countinue with Apple

 

onRequest: @eascaping (ASAuthorizationAppleIDRequest) → Void

컴포넌트의 두번째 매개변수인 onRequest는 Apple ID에 권한을 요청하는 클로저이고, 매개변수로 ASAuthorizationAppleIDRequest를 준다. 얘는 ASAuthorizationAppleIDProvider 클래스의 createRequest 메서드를 실행하면 얻을 수 있다. 하지만 SwiftUI에서는 버튼을 기본으로 제공하면서 인스턴스를 만들고 메서드를 실행하는 단계를 생략할 수 있다. 공식문서에 있는 UIKit 예제 코드를 보면 좀 더 확실하게 이해 할 수 있다.

func setupProviderLoginView() {
    let authorizationButton = ASAuthorizationAppleIDButton()
    authorizationButton.addTarget(self, action: #selector(handleAuthorizationAppleIDButtonPress), for: .touchUpInside)
    self.loginProviderStackView.addArrangedSubview(authorizationButton)
}
@objc
func handleAuthorizationAppleIDButtonPress() {
	// 직접 ASAuthorizationAppleIDProvider 인스턴스를 만들고,
    let appleIDProvider = ASAuthorizationAppleIDProvider()
    // createRequest 메서드를 호출해서 ASAuthorizationAppleIDRequest를 만든다.
    let request = appleIDProvider.createRequest()
    // 그리고 해당 요청에 필요한 추가작업을 해준다.
    request.requestedScopes = [.fullName, .email]
    
    let authorizationController = ASAuthorizationController(authorizationRequests: [request])
    authorizationController.delegate = self
    authorizationController.presentationContextProvider = self
    authorizationController.performRequests()
}

 

이제 onRequest 클로저의 매개변수인 ASAuthorizationAppleIDRequest가 어디서 오는 녀석인지 알았으니까 이제 이걸로 뭘 해야하고, 뭘 할 수 있는지 살펴보자.

 

ASAuthorizationAppleIDRequest의 프로퍼티

ASAuthorizationAppleIDRequest의 프로퍼티는 user 딱 하나밖에 없다. 이 user는 "유저의 Apple ID와 관련된 식별자"라고 정의되어 있고, 기본값은 nil이라고 한다. 일단 요청을 보내는 시점, 특히 회원가입을 진행하는 시점에서는 여기에는 값이 없는게 맞다. 잘 읽어보면, "ASAuthorizationAppleIDCredential 인스턴스가 포함된 인증을 받은 경우 이 속성을 인증 정보의 사용자 속성 값으로 설정하십시오."라고 되어 있는데, 이건 아마 재로그인과 같이 이미 user를 특정할 수 있고 그 해당 user에 대한 인증을 다시한번 요구할때는 request를 보낼 시에 여기다가 기존에 받아온 credential을 넣어주면 되는게 아닌가 싶다. 자세한건 더 파헤치다보면 알게 되겠지! 그럼 다음으로 넘어가서!

 

ASAuthorizationAppleIDRequest만 보면 가지고 있는 고유 프로퍼티는 위에서 말한 user밖에 없지만, 얘는 기본적으로 ASAuthorizationOpenIDRequest를 상속받기 때문에 OpenIDRequest의 프로퍼티도 당연히 사용할 수 있다. 

ASAuthorizationOpenIDRequest의 프로퍼티

- requestedOperation : Open ID 인증 작업중에 뭘할건지 지정할 수 있는 프로퍼티이고, operationLogin, operationRefresh, operationLogout, operationImplicit 4가지가 있고, 아무것도 지정하지 않으면 operationLogin을 기본값으로 한다. 앞의 세가지는 이름에서 어떤 기능인지 충분히 유추가 가능하고, operationImplicit는 provider에 따라서 작업이 달라진다고 한다. (자동 로그인이나 로그아웃을 구현할 때 써먹을 수 있을 것 같다.)

 

 

- requestedScope : 자격증명을 요청할때 추가로 요청할 유저에 대한 정보를 담는 배열이다. 당장 위의 UIKit 코드를 보면 이런 부분이 있다.

request.requestedScopes = [.fullName, .email]

요청할 수 있는 것은 사용자의 이름(fullName)과 email 밖에 없다. 근데 여기서 주의해야 하는게 이런 사용자 정보는 맨처음 요청! 딱 한번! 밖에 주지않는다. 그래서 이메일이든 이름이든 처음 요청을 했을 때 키체인에다가 잘 저장해놔야 한다. 조금 더 쉽게 설명하자면, 사용자가 내 앱에서 Apple 계정으로 로그인을 한다고 하자. 이사람은 신규 유저라서 회원가입부터 진행될 것이다. 그래서 맨처음 지금 권한을 요청할때는 response에 email이나 이름이 담겨온다. 하지만, 이후 이사람이 로그인을 할때는 requestedScopes에 뭐가 담겨있든 response쪽에서 아무리 이름이나 메일을 확인하려고 해도 nil로 나온다. 그래서 이걸 이용해서 신규유저인지를 판별할 수도 있다.

 

- state : "인증 성공 후 해당 자격 증명에서 수정되지 않은 상태로 반환되는 데이터"

- nonce : "identity 공급자에게 보내는 문자열 값"

이렇게 되어 있는데 이 두가지는 보안과 관련된 프로퍼티다. 특히 재전송 공격(Replay Attack)*을 방어하기 위해 사용하는데, request를 보낼때 state와 nonce에 고유한 값의 암호화된 어떤 값을 넣어서 보내고 응답을 받았을 때 그 값이 보냈을 때와 동일한 값인가를 확인해서 무결성을 확인하는 방식으로 사용할 수 있다. 둘 다 App에서 생성되고 인증 요청에 포함되어 서버로 전달되는 문자열 값이고, 추측하거나 재사용할 수 없는 고유하고 불투명한 값이어야 한다.

state는 권한 요청과 권한 서버로부터 받은 콜백 사이의 상태를 유지하기 위해 사용되고, 인증 흐름이 완료된 후 "로컬"에서 확인하여 시스템의 다른 App에서 권한 부여 요청이 전송되었는지 확인한다.

nonce는 인증 서버에서 반환된 ID 토큰이 최신인지 확인하는 데 사용되고, 백엔드 서버에서 이전 요청이 재생되지 않는지 확인한다.

밑에서 살펴보겠지만, 이런 특성때문에 state는 responce에서 프로퍼티로 확인할 수 있지만, nonce는 그냥은 확인할 수 없고 OAuth에서 credential을 만들때 활용하거나 받아온 토큰을 decode해야지만 확인할 수 있다. 가능하면 이 시리즈 포스팅에서 해당 프로퍼티를 활용하는 방법까지 다뤄보려고 한다.

 

* 재전송 공격(Replay Attack) : 사용자가 로그인할 때 쿠키의 정보를 해커가 스니핑(Sniffing)**하여 Opera 웹브라우저의 쿠키 관리자에서 강제로 재전송하여 위장할 수 있는 공격

** 스니핑(Sniffing) : 네트워크 상에서 자신이 아닌 다른 상대방들의 패킷 교환을 엿듣는 것

즉, 우리가 서버에 보내는 request를 도중에서 가로채기 한 다음 그 request를 사용해서 response를 빼돌려 정보를 탈취하는 것이다.

 

그럼 다음엔 요청을 보냈으니 응답으로는 뭘 받는지 살펴보자

 

onCompletion: @escaping ((Result<ASAuthorization, Error>) -> Void)

onCompletion은 보낸 요청에 대한 결과를 받아서 처리하는 클로저로 매개변수로 Result를 받는다. 당연히 이 Result는 위의 onRequest에서 보낸 요청에대한 Response를 담고 있다. Result<ASAuthorization, Error>라고 되어 있어서 눌러보니 Swift에서 기본적으로 제공하는 Result라는 구조체가 있다!! enum 타입이고 앞에는 성공시, 뒤에는 실패시의 결과를 저장하는거라고 한다. (enum 타입이니까 switch .success 같은걸로 분기가 가능하겠지!?) 그럼 얘는 앞에서 보낸 request의 결과가 success면 ASAuthorization을 담고 있을거고, failure면 Error를 담고 있다는 말일거다.

 

ASAuthorization은 뭐냐...."The encapsulation of a successful authorization by a controller"라고 되어있는데 대충 by controller는 무시해도 되고, 권한 요청이 성공했을 때 그걸 잘 싸가지고 캡슐화해서 보내준 응답물이다. 요기 안에는 모가 들어있냐면, 

ASAuthorization class가 가지고 있는 프로퍼티

provider는 당연히 권한을 준 공급자에 대한 정보이고, 우리는 이미 이게 무엇인지 알고 있다. 위의 UIKit 코드에서 우리가 직접 이친구를 정의했다.

let appleIDProvider = ASAuthorizationAppleIDProvider()

 

그렇다면 우리가 주목할 것은 credential이다! 요 credential은 ASAuthorizationCredential 클래스이지만, 우리는 이게 어디서 받아온 권한인가? Apple! 그럼 요걸 ASAuthorizationAppleIDCredential로 as 연산자를 사용해서 다운캐스팅 해보자. ASAuthorizationAppleIDCredential은 어차피 ASAuthorizationCredential의 프로토콜을 따르고 있다. ASAuthorizationCredential 자체는 꺼내서 쓸 수 있는 정보가 없지만, AppleIDCredential로 다운캐스팅 하면 우리는 3종류의 프로퍼티를 얻을 수 있다.

일단 그 중에 유저 식별에 관련된 프로퍼티부터 알아보자.

ASAuthorizationAppleIDCredential의 프로퍼티

- identityToken : 사용자에 대한 정보를 앱에 안전하게 전달하는 JWT(JSON Web Token)

- authorizationCode : 앱이 서버와 상호작용하는데 사용되는 토큰..이라는데

자료가 너무 부실해서 google과 공식문서를 열심히 뒤져봤다.

https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/authenticating_users_with_sign_in_with_apple#3383773

 

Authenticating users with Sign in with Apple | Apple Developer Documentation

Securely authenticate users and create accounts for them in your app.

developer.apple.com

 

일단 identityToken은 사용자에 대한 정보가 담겨있는 토큰이고 10분간 유효하다. RS256 알고리즘으로 서명되어 있어서 utf8로 디코딩하고 https://jwt.io/ 에 넣어보면(그리고 위 링크에 들어가면) 뭐가 담겨있는지 확인할 수 있다. 그리고 다음절차는 서버로 넘겨서 서버에서 처리를 해야하는 부분인데... 일단 위의 토큰과 코드를 서버로 보낸다. 그럼 서버는 토큰 서명을 확인하기 위해서 Apple의 공개키를 가져온다. 그리고 받아온 공개키를 이용해서 identityToken을 복호화 해서 사용자에 대한 정보를 얻는다.

그리고 authorizationCode는 1번만 사용될 수 있고 5분간 유효하다. 애플 공식문서에는 이 코드를 사용해서 Apple 서버에서 토큰 클래임을 확인하고 refresh 토큰으로 교환하라고 적혀있다. 토큰 교환에 대한 공식문서는 

https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens

 

Generate and validate tokens | Apple Developer Documentation

Validate an authorization grant code delivered to your app to obtain tokens, or validate an existing refresh token.

developer.apple.com

그리고 php로 구현된 것이지만, 중간중간에 도움되는게 많아서 참고했던 글...

https://darkstart.tistory.com/116

 

[php]애플 아이디로 로그인 refresh token 구하기?

애플 공식홈의 자료가.. 좋지 않습니다. 일단.. iOS 앱에서 로그인 후 받는 정보 중에서 JWT 형식의 identityToken을 검증했습니다. 그리고 그 다음 과정으로는 https://appleid.apple.com/auth/token 위의 주소에

darkstart.tistory.com

암튼 이것도 나중에 탈퇴 기능 구현하려면 해야하는거기 때문에 다음 포스팅으로 미루고 ... 다시 본론으로 돌아가서

 

- state: "앱이 자격 증명을 생성하는 요청에 제공하는 임의의 문자열입니다." 라고 되어 있는데, request에서 끼워보낸 그 state가 맞다. 그래서 응답이 돌아왔을 때 이 state가 보냈을때와 동일하게 왔는지, 앱에서 한번 확인해주면 된다.

 

- user : 인증된 사용자의 식별자인데, 이것도 request에 있었던것을 기억하는가? requestedOperation을 로그인이 아닌 리프레쉬나 로그아웃으로 할때 여기서 얻은 user를 고대로 다시 넣어주면 된다... 근데 일단 operationRefresh는 아무리 뒤져도 사용 예시를 찾을 수가 없어서 결국 이게 뭘 요청하는건지 알 수 없었고, operationLogout도 위에 접어둔 부분에 첨부해놨는데 갑자기 로그인할때의 apple UI가 호출되어서 이걸 쓰는 사람이 없는것 같다....

 

그리고 두번째가 requestedScope로 요청했던 유저의 추가 정보에 대한 프로퍼티다.

다시 한 번 더 강조하지만, 여기서 얻는 fullName과 email은 최초의 첫 request에 대한 result에만 담겨오기 때문에 키체인 등을 사용해서 적절히 저장해주어야 하고, 그렇지 않은 경우는 위에서 얻은 identityToken을 디코딩해서 직접 정보를 빼내야 한다. 두번째 request부터는 nil로 나온다.

fullName 같은 경우는 실명은 아니고, 환경설정 - Apple ID - 이름, 전화번호, 이메일 - 이름에 사용자가 적은 이름이 온다. String값이 오는게 아니라 PersonNameComponents라는 구조체로 오고 해당 구조체에 이렇게나 많은 프로퍼티가 있다.

여기서 일단 givenName(이름)과 familyName(성)은 필수 항목이므로 두개를 꺼내서 적절히 처리하면 된다.

 

email의 경우에는 사용자가 처음에 가입할 때 이메일 가리기를 사용하는 경우 @privaterelay.appleid.com 이런 도메인으로 제공됨에 주의하자.

 

마지막 세번째는 Detecting User Characteristics...인데 여기는 프로퍼티가 realUserStatus 하나 있다. 설명을 보면 사용자가 실제 사람인지를 나타내는 값이라고 한다. Discussion에는 사기를 방지하려고 할때 사용할 수 있고, 사용자가 실존 인물이라는 높은 신뢰도를 시스템이 가지고 있다는 힌트지만, 또 막상 보장되는 것은 아니라 한다......(뭐... 어쩌라고;;;)

암튼 이건 enum 구조체이고 3가지 케이스를 가지는데,

ASUserDetectionStatus 구조체의 case

이름에서도 알 수 있듯이 likelyReal > unknown > unsupported 순으로 신뢰도가 높다. 이건, 사용자 인증 및 정보요청 여기서 자세한 내용을 확인할 수 있다. (근데 이걸 어디다 써먹.....)

 

일단 뭐 이정도..? 애플에서 주는 기본 Sign in with Apple Button 컴포넌트만 파헤쳐 봤는데, 공식문서 자체가 설명이 부실한 부분이 많아서 추가적인 구글링으로 알게된 게 더 많았다. 공식문서에 대한 관련 링크는 글 중간중간에 하이퍼링크로 달아놨고, 추가 참고자료는 아래쪽에 달아뒀다.

이게 일단 로그인하고 인증권한을 받아오는거까지는 참 버튼 하나로 쉽게 할 수 있게 되어있는데, 그외의 로그아웃이나 회원탈퇴 같은 기본적인 기능은 또 함수로 구현이 안되어있어서 위에서 언급한 identityToken, authorizationCode, user 등을 적절히 이용해서 Apple REST API에 직접 통신해서 얻어와야한다;;; 원래라면 이건 백엔드에서 하는 일지만서도.... 지금 하고 있는 프로젝트에서는 백엔드나 서버를 따로 두고 있지 않기때문에 어떻게든 swift로 클라이언트 내에서 해결해 보려고 하고 있다.

 

 

참고자료

제36회 한국정보처리학회 추계학술발표대회 논문집  제18권 제2호 (2011. 11) 웹 어플리케이션에서의
재전송 공격 방어 알고리즘

https://koreascience.kr/article/CFKO201121868479442.pdf

 

Sign in with Apple Unprotected from Replay Attacks

https://support.hcltechsw.com/csm?id=kb_article&sysparm_article=KB0090825 

 

Sign in with Apple Unprotected from Replay Attacks - Customer Support

 

support.hcltechsw.com

 

블로그의 정보

Roen의 iOS 개발로그

Steady On

활동하기