iamkanguk.dev

[맵필로그] 아주 많이 고생한 애플로그인 구현 기록..! (Passport X) 본문

사이드 프로젝트/맵필로그

[맵필로그] 아주 많이 고생한 애플로그인 구현 기록..! (Passport X)

iamkanguk 2024. 2. 6. 14:27

우리 맵필로그 프로젝트에서는 이제 배포를 슬슬 준비하고 있다. Android와 iOS 모두 준비하고 있는데 알아보니까 iOS 앱스토어 배포를 위해서는 로그인 기능을 제공할 경우에는 애플 소셜 로그인이 필수로 구현되어 있어야 한다고 한다.

 

그래서 오늘은 약 3일간 고생한 애플 로그인을 어떻게 구현했는지에 대해서 알아보려고 한다. 아마 NestJS로 구현을 하시는 분들은.. 아실 거지만 생각보다 자료가 많이 없다는 것을 느끼셨을 것이다. 그래서 필자는 Spring으로 구현한 개발자분들의 자료도 많이 참고했었다.

 

지금부터 쭉 설명을 해보도록 하겠다.

 

1.  사전 준비물 및 언급

- 애플 개발자 계정이 꼭 필요하다. 혹시나 애플 로그인 기능을 구현하고자 하는 분들은 개발자 계정을 꼭 구매하길 바란다.

- 그리고 이 포스팅은 Passport 라이브러리를 사용하지 않는다. 필자는 웹사이트가 아닌 애플리케이션 배포를 목적으로 하기 때문에 Passport 라이브러리는 부적절하다고 판단했다.

- 또한 Redirect 개념이 없다. 웹사이트를 상대로 구현하는 것이 아니기 때문이다.

- 그리고 필자는 사용자 이름이 프로젝트에서 필요없기 때문에 따로 받아오지 않는다.

 

2.  애플 개발자 계정 설정

(1) 애플 개발자 사이트에 들어가서 Account로 들어간다.

(2) 식별자(Identifier)로 들어가준다.

(3) 식별자로 들어가서 파란색 플러스 버튼을 눌러주고 App ID를 선택해준다.

(4) 이후 App을 선택해주고 Description과 Bundle ID를 적어준다. 그리고 밑에서 Sign In With Apple을 클릭하고 Continue를 눌러준다.

Description은 AppID에 대한 설명을 입력하는 것이니 별로 중요하지 않다. Bundle ID는 보통 도메인의 역순을 사용한다고 한다. 사진에 보면 예시가 쪼꼬맣게 써있으니 참고해서 얼추 쓰면 된다.

 

여기까지 하면 일단 App ID는 세팅이 완료된 것이다.

(5) Service ID를 생성하라는 게시물이 많은데, 필자는 앱 배포를 목표로 하고있기 때문에 굳이 생성하지 않아도 된다. Service ID는 웹사이트를 기획할 때 생성해주면 된다.

(6) 메뉴로 돌아와서 Keys로 들어오고 마찬가지로 파란색 플러스 버튼을 눌러준다.

(7) 키 이름을 입력해주고 Sign in With Apple을 선택해준다. 그리고 옆에 Configure을 눌러주고 위에서 만든 App ID를 선택한다.

(8) 이후 저장하게 되면 새로운 키 ID가 생성되고 키를 다운로드 할 수 있다. 생성된 키는 다시 다운로드가 안되니까 잘 보관해두자.

 

이제 어느정도 세팅은 끝났다. 지금부터는 로그인 과정에 대해서 설명하고, 코드레벨로 넘어가보도록 하겠다.

 

3.  로그인 과정

사실 다른 소셜 로그인 보다 애플로그인은 많이 어려운 편에 속하는 것 같다. 그래도 최대한 간단하게 설명을 드려보도록 하겠다.

다른 개발자분이 해당 방식을 잘 설명해주셔서 나름 이해가 편했던 것 같다. 아래 참고링크에도 올려놓을테니 한번 들어가보시는 것도 좋을 것 같다.

Flow는 위의 그림과 아주 동일하다. 간단하게 중요한 것만 찝어내면..

  1. App에서 맵필로그 서버로 authorization code를 보내준다. 아마 App에서는 Apple로 부터 authCodeString, identifyTokenString, useridentifier를 받아오는 것으로 알고 있다.
  2. 맵필로그 서버에서는 App에서 보내온 authorization code(authCodeString)를 가지고 Apple 서버로 요청을 보내서 검증을 한다. 검증을 하게 되면 애플에서 access token, refresh_token, id_token 과 그 외의 정보들을 제공해준다.
  3. id_token을 까보면 그 안에 sub, email, 만료 정보 등이 있다. 우리는 이걸 가지고 이제 App과 맵필로그 서버 사이에서 회원가입 및 로그인 처리를 해주면 된다.

 

사실 Flow를 보면 그리 어렵지는 않다. 이런 과정들을 알기 까지 조금 힘들었던 것 같고, 코드적인 부분에서 많이 헤맸던 것 같다.

지금부터는 차근차근 코드를 설명해보려고 한다.

4.  코드 및 구현 과정 설명

(1) ENV 파일에 저장해야 할 것들

사실 코드보다 더 머리가 아픈게 ENV에 저장해야 할 키들이다. 진짜 너무너무 헷갈렸다. 어떤 자료는 이거, 다른 자료는 이거 각 블로그마다 설명하는게 달라서 어떤 걸 사용해야 할지 막막했는데 이제서야 정리가 된 것 같다.

  • KEY_ID: 위에서 만든 key로 들어가면 Key ID를 볼 수 있다. (Certificates, Identifiers & Profiles -> keys)
  • TEAM_ID: Identifiers에서 만들어놓은 거 들어가면 App ID Prefix를 볼 수 있다. 그게 Team ID다.
  • CLIENT_ID: 제일 골치아팠던 놈. 그냥 결론만 말한다. 나처럼 기능을 구현할 예정인 개발자라면 TEAM_ID 확인하는 곳 바로 밑에보면 Bundle ID라고 있다. 그걸 저장하자!!

(2) Secret Key (인증키) 생성하는 함수

Client Secret을 생성하는 자료는 애플 개발자 공식 문서에 있으니까 참고하면 좋을 것 같다.

 

- Header

 

  • alg(algorithm): 토큰 서명 알고리즘을 의미하는데 ES256을 사용하면 된다.
  • kid(keyid): KEY_ID를 의미한다.

- Payload

  • iss(issuer): TEAM_ID를 의미한다.
  • iat: 토큰 생성시간
  • exp(expiresIn): 토큰 만료시간
  • aud(audience): client_secret 유효성 검사 서버. https://appleid.apple.com을 사용한다.
  • sub(subject): CLIENT_ID를 의미한다.
/**
   * @summary 애플 Secret Key를 생성하는 함수
   * @author  Jason
   * @returns { string }
   */
  generateAppleClientSecret(): string {
    try {
      const applePrivateKey = fs.readFileSync(
        'apple-social-login-key.p8',
        'utf8',
      );

      return jwt.sign({}, applePrivateKey, {
        algorithm: 'ES256',
        expiresIn: 3600,
        audience: 'https://appleid.apple.com',
        issuer: this.customConfigService.get<string>(
          ENVIRONMENT_KEY.APPLE_KEY_TEAM_ID,
        ), // TEAM_ID ==> APP ID PREFIX
        subject: this.customConfigService.get<string>(
          ENVIRONMENT_KEY.APPLE_KEY_CLIENT_ID,
        ), // CLIENT_ID ==> APP BUNDLE ID
        keyid: this.customConfigService.get<string>(
          ENVIRONMENT_KEY.APPLE_KEY_ID,
        ), // APP KEY ID
      });
    } catch (err) {
      this.logger.error(`[generateAppleClientSecret] SecretKey 생성 오류`);
      throw new InternalServerErrorException(
        InternalServerExceptionCode.CreateAppleSecretKeyError,
      );
    }

 

먼저, try-catch문을 최대한 쓰려고 했다. 왜냐면 애플 로그인에서는 예상치 못하게 에러가 많이 발생하는 것 같았다. 그래서 에러마다 처리를 하기는 어려우니까 catch 문에서 에러를 받게되면 서버 내부 에러로 처리하게 했다.

 

인증키를 생성하는 이유는 나중에 애플 서버에 요청을 할 때 필요하기 때문이다. 먼저, 아까 위에서 세팅할 때 저장했던 p8 파일을 readFileSync를 통해 불러와준다. 그리고 sign을 통해 jwt 키를 만들어줘야 한다. 이 때 위에서 두껍게 되어있는 부분을 sign에 넣어줘야 한다.

 

(3) (2)에서 만든 Client_Secret + authorization_code를 가지고 Apple에 요청해서 데이터 받기

export interface IVerifyAppleAuthCode {
  access_token: string;
  refresh_token: string;
  id_token: string;
  token_type: string;
  expires_in: number;
}

/**
   * @summary 애플 authorization_code를 가지고 Apple 서버에 검증을 요청하고 나온 결과 반환
   * @author  Jason
   * @param   { string } code
   * @returns { Promise<IVerifyAppleAuthCode> }
   */
  async validateAppleSocialAccessToken(
    code: string,
  ): Promise<IVerifyAppleAuthCode> {
    try {
      return (
        await axios.post(
          'https://appleid.apple.com/auth/token',
          querystring.stringify({
            grant_type: 'authorization_code',
            code: code,
            client_secret: this.generateAppleClientSecret(),
            client_id: this.customConfigService.get<string>(
              ENVIRONMENT_KEY.APPLE_KEY_CLIENT_ID,
            ),
          }),
          {
            headers: {
              'Content-Type': 'application/x-www-form-urlencoded',
            },
          },
        )
      ).data as IVerifyAppleAuthCode;
    } catch (err) {
      this.logger.error(
        '[validateSocialAccessToken] 애플 로그인 처리 중 에러 발생.',
      );
      throw new UnauthorizedException(
        UserExceptionCode.AppleSocialLoginTokenError,
      );
    }
  }

 

https://appleid.apple.com/auth/token으로 요청을 한다. 이 때 Content-Type은 application/x-www-form-urlencoded를 필수로 설정을 해야하고, body를 URL Encoding을 필수로 해야하기 때문에 필자는 querystring 라이브러리의 stringify를 사용했다.

 

그렇게 axios 요청을 해서 나온 결과값에서 data 부분만 우리는 가져오면 된다. 그러면 IVerifyAppleAuthCode 인터페이스 처럼 결과값이 나온다. 우리는 여기서 id_token만 사용하면 된다.

 

여기까지 하면 사실 작업은 거의 끝났다. 아래에 나머지 설명을 조금 더 하고 마무리 하겠다.

 

(4) id_token decode

  • id_token을 jwt.decode 하게 되면 다음과 같은 정보를 받게 된다.

 

우리는 여기서 sub와 email만 추출하면 된다. sub는 소셜 로그인 고유값이기 때문에 DB에 저장해서 사용자 식별자로 사용하면 된다. 그리고 email은 애플 로그인 이메일인데 애플 로그인 할 때 이메일을 가릴 수 있는걸로 아는데 이 때 email은 애플 쪽에서 private 하게 랜덤으로 만들어서 제공해준다. 

 

사실 우리 서비스에서 email도 크게 중요하지는 않기 때문에 중요하게 판단하지 않아도 괜찮겠다고 생각했다. 

 

(5) 주의 사항

4번 단계까지 마무리하면 이제 애플로그인은 어느정도 마무리 되었다고 할 수 있다. 이제 프로젝트 서버에서 JWT를 발급해주기만 하면 마무리가 된다. 이제는 이 글을 읽으시는 분들은 직접 데이터를 가공해서 작업할 수 있다고 생각한다.

 

한 가지 주의사항이 있다. 필자는 처음 개발할 때 클라이언트에서 제공받은 authorization_code(authCodeString)을 가지고 애플 서버에 요청(위에 validateAppleSocialAccessToken 메서드)을 보내는 걸 2번했었다. 

 

이전에 작성한 코드 스타일을 맞추기 위해서 그랬던 것 같다. 그리고 크게 성능에 영향을 미치지 않을 것이라고 판단했다. 하지만 카카오에서는 크게 문제가 없지만 애플에서는 이를 막고 있었다.

 

클라이언트의 authorization_code를 가지고 Apple에 요청을 다시 또 보내게 되면 그 code는 다시 또 사용할 수 없다. 그러면 에러에서 already used 라고 에러가 발생하게 된다.

 

그래서 우리는 무조건 처음 Apple 서버로의 요청을 통해 결과값을 받으면 처음 단계에서 무조건 저장을 해놓고 사용을 해야한다. 요청 이후 또 요청을 하게 되면 에러가 마구마구 발생할 것이니 이는 꼭 주의해야 한다.


지금까지 이렇게 시간을 아주 많이.... 투자한 애플로그인을 마무리했다. 카카오 로그인은 Kakao Developers가 너무너무 잘 작성되어 있고 자료도 많아서 구현하는데 어려움이 많이 없었다.

 

하지만 애플 로그인 같은 경우에는 문서도 친절하지 않고 Nest로 되어있는 자료들이 정말 많지 않아서 힘들었다. Stack Overflow에도 질문을 했는데 답변도 안달리고,,,ㅎ

 

어쨌든 필자가 하는 방법이 정확하지는 않을수도 있지만 그래도 이 글을 통해 다른 개발자 분들에게 조금이나마 도움이 되었으면 한다.

오늘은 나름 편하게 잠을 잘 수 있을 듯 하며.... ㅂ2

 

참고자료

- https://blog.devgenius.io/how-to-implement-apple-login-with-nestjs-in-seconds-b88f05abe847

 

How to implement Apple Login with Nestjs in seconds

Nestjs is a framework for building efficient, scalable Node.js server-side applications. It uses modern JavaScript, is built with…

blog.devgenius.io

- https://tlog.tammolo.com/posts/apple-login-02

 

Apple Login for Web (하) - Node + React 구현 - Tlog

리액트 + Node 서버에서 애플 로그인 (OAuth, React)

tlog.tammolo.com

- https://shxrecord.tistory.com/289

 

[Spring Boot]애플 로그인 구현

앱스토어 배포시에 애플 로그인이 필요하다는 말에 개발을 하게 됐었는데 구현이 다른 소셜 로그인에 비해 꽤나 복잡했었습니다. 언젠가 또 개발할 일이 있지 않을까라는 생각에 기록을 남겨봅

shxrecord.tistory.com