Super Kawaii Cute Cat Kaoani [Spring boot][Apple Login] 애플로그인 구현

[Spring boot][Apple Login] 애플로그인 구현

2024. 3. 9. 02:17
728x90
SMALL
SMALL

 

매우 악명이 높은 애플로그인을 ..... 구현해보았습니다...... 

제가 이해한 애플로그인의 방법은

1. 프론트로부터 authorization_code를 넘겨받고

2. 해당 코드로 유저정보 및 access token을 발급받고 

3. DB에 없는 회원이라면 회원가입을 시킨 후 로그인하고, 기존회원이라면 로그인을 시킨 후

4. 토큰을 반환하는 식으로... 구현하였습니다

 

spring:  
  security:
    oauth2:
      client:
        registration:
          apple:
            clientId: [Bundle ID]
            clientSecret: /home/ec2-user/app/[keyname.p8]
            redirect-uri: https://[domain.com]/login/oauth2/code/apple
            authorization-grant-type: authorization_code
            client-authentication-method: POST
            client-name: Apple
            scope:
              - name
              - email
              
social-login:
  provider:
    apple:
      key-id: [생성된 키 KEY_ID ]
      team-id: [생성된 TEAM_ID]
      redirect-uri: https://[domain.com]/login/oauth2/code/apple

client_id가  Service ID의 identifier 라는 얘기가 많아서 해당 아이디를 입력했는데 계~~~~~~속 이유 없이 로그인이 안됐다....
프론트로부터 전달받은 authorization_code로 apple에 요청보내면 거기서 에러가 났다.

유효하지 않은 code 인가 싶어 계속 들여다보고 고민했는데 .. 아무리 생각해도 사용한 적도 없는 방금 발급받아서 바로 요청한 code라서 왜 에러가 나는지 몰랐다..

에러를 출력해보니 client_id가 일치하지 않는다는 에러가 나왔다 !! 알고보니 Service ID의 identifier가 아니라 Bundle ID를 입력했어야했던것 !!!.,,,... 그렇게 했더니 바로 성공 ㅠㅠ 기쁨의 눈물을 흘렸다....

 

 

✅ CustomEntity

package yerong.baedug.oauth;

import com.fasterxml.jackson.annotation.JsonInclude;
import lombok.Data;

@Data
@JsonInclude(JsonInclude.Include.NON_NULL)
public class CustomEntity {

    private String id;
    private Object result;

    public CustomEntity(String id, Object result) {
        this.id = id;
        this.result  = result;
    }
}

 

 

✅ AuthController

 

package yerong.baedug.oauth;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import yerong.baedug.dto.request.MemberRequestDto;
import yerong.baedug.service.MemberService;

@RestController
@RequiredArgsConstructor
@Slf4j
@RequestMapping("/login")
public class AppleController {

    private final AppleService appleService;
    private final MemberService memberService;

    @PostMapping("/oauth2/code/apple")
    public ResponseEntity<?> callback(@RequestParam("code") String code) throws Exception {
        try {

            AppleDto appleInfo = appleService.getAppleInfo(code);
            // 신규 회원 저장

            MemberRequestDto memberRequestDto = MemberRequestDto.builder()
                    .email(appleInfo.getEmail())
                    .socialId(appleInfo.getId())
                    .build();
            memberService.saveMember(memberRequestDto);

            return ResponseEntity.ok(new CustomEntity("Success", appleInfo));

        }catch (Exception e){
            log.error("Apple 소셜 로그인 오류: {}", e.getMessage());
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
                    .body(new CustomEntity("Error", "Apple 소셜 로그인 오류"));
        }

    }
}
  • 프론트에서 로그인을 진행하면 apple에서 authorization_code를 받아 서버로 넘겨준다
  • @RequestParam을 사용해서 받음!
  • AppleService에 구현된 메서드로 호출해 필요한 정보를 받아옴
  • "user"로 user 정보도 받아올 수 있었는데 나는 받아오지 않았다
    • 최초 회원가입시 딱 한번만 user를 주기 때문에 로그인을 해제하고 다시 해야함.. 
    • https://appleid.apple.com/ 여기서 로그인 해제를 통해 다시 받아오면 된다

 

728x90

✅ AuthService

package yerong.baedug.oauth;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.nimbusds.jose.JOSEException;
import com.nimbusds.jose.JWSAlgorithm;
import com.nimbusds.jose.JWSHeader;
import com.nimbusds.jose.JWSSigner;
import com.nimbusds.jose.crypto.ECDSASigner;
import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.ReadOnlyJWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import net.minidev.json.JSONObject;
import net.minidev.json.parser.JSONParser;
import org.bouncycastle.util.io.pem.PemObject;
import org.bouncycastle.util.io.pem.PemReader;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestTemplate;

import javax.management.openmbean.InvalidKeyException;
import java.io.*;
import java.net.URL;
import java.security.KeyFactory;
import java.security.interfaces.ECPrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Date;

@RequiredArgsConstructor
@Service
@Slf4j
public class AppleService {

    @Value("${social-login.provider.apple.team-id}")
    private String APPLE_TEAM_ID;

    @Value("${social-login.provider.apple.key-id}")
    private String APPLE_LOGIN_KEY;

    @Value("${spring.security.oauth2.client.registration.apple.clientId}")
    private String APPLE_CLIENT_ID;

    @Value("${social-login.provider.apple.redirect-uri}")
    private String APPLE_REDIRECT_URL;

    @Value("${spring.security.oauth2.client.registration.apple.clientSecret}")
    private String APPLE_KEY_PATH;

    private final static String APPLE_AUTH_URL = "https://appleid.apple.com";

    public String getAppleLogin() {
        return APPLE_AUTH_URL + "/auth/authorize"
                + "?client_id=" + APPLE_CLIENT_ID
                + "&redirect_uri=" + APPLE_REDIRECT_URL
                + "&response_type=code%20id_token&scope=name%20email&response_mode=form_post";
    }
    public AppleDto getAppleInfo(String code) throws Exception {
        if (code == null) throw new Exception("Failed get authorization code");

        String clientSecret = createClientSecret();
        String userId = "";
        String email  = "";
        String accessToken = "";


        try {

            HttpHeaders headers = new HttpHeaders();
            headers.add("Content-type", "application/x-www-form-urlencoded");


            MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
            params.add("grant_type"   , "authorization_code");
            params.add("client_id"    , APPLE_CLIENT_ID);
            params.add("client_secret", clientSecret);
            params.add("code"         , code);
            params.add("redirect_uri" , APPLE_REDIRECT_URL);


            RestTemplate restTemplate = new RestTemplate();
            HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(params, headers);

            ResponseEntity<String> response = restTemplate.exchange(
                    APPLE_AUTH_URL + "/auth/token",
                    HttpMethod.POST,
                    httpEntity,
                    String.class
            );

            JSONParser jsonParser = new JSONParser();
            JSONObject jsonObj = (JSONObject) jsonParser.parse(response.getBody());

            accessToken = String.valueOf(jsonObj.get("access_token"));

            //ID TOKEN을 통해 회원 고유 식별자 받기
            SignedJWT signedJWT = SignedJWT.parse(String.valueOf(jsonObj.get("id_token")));
            ReadOnlyJWTClaimsSet getPayload = signedJWT.getJWTClaimsSet();

            ObjectMapper objectMapper = new ObjectMapper();
            JSONObject payload = objectMapper.readValue(getPayload.toJSONObject().toJSONString(), JSONObject.class);

            userId = String.valueOf(payload.get("sub"));
            email  = String.valueOf(payload.get("email"));

        } catch (Exception e) {
            e.printStackTrace();
            throw new Exception("API call failed", e);
        }

        return AppleDto.builder()
                .id(userId)
                .token(accessToken)
                .email(email)
                //.username(username)
                .build();
    }

    private String createClientSecret() throws Exception {
        JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(APPLE_LOGIN_KEY).build();
        JWTClaimsSet claimsSet = new JWTClaimsSet();

        Date now = new Date();
        claimsSet.setIssuer(APPLE_TEAM_ID);
        claimsSet.setIssueTime(now);
        claimsSet.setExpirationTime(new Date(now.getTime() + 3600000));
        claimsSet.setAudience(APPLE_AUTH_URL);
        claimsSet.setSubject(APPLE_CLIENT_ID);

        SignedJWT jwt = new SignedJWT(header, claimsSet);

        PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(getPrivateKey());
        KeyFactory kf = KeyFactory.getInstance("EC");
        try {
            ECPrivateKey ecPrivateKey = (ECPrivateKey) kf.generatePrivate(spec);

            JWSSigner jwsSigner = new ECDSASigner(ecPrivateKey.getS());
            jwt.sign(jwsSigner);

        } catch (InvalidKeyException | JOSEException e) {
            throw new Exception("Failed create client secret");
        }

        return jwt.serialize();
    }

    private byte[] getPrivateKey() throws Exception {
        byte[] content = null;
        File file = new File(APPLE_KEY_PATH);
        URL res = getClass().getResource(APPLE_KEY_PATH);

        if (res == null) {
            // 파일 시스템에서 파일을 로드할 때
            file = new File(APPLE_KEY_PATH);
        } else if ("jar".equals(res.getProtocol())) {
            // JAR 파일 내부의 리소스를 읽을 때
            try {
                InputStream input = getClass().getResourceAsStream(APPLE_KEY_PATH);
                file = File.createTempFile("tempfile", ".tmp");
                OutputStream out = new FileOutputStream(file);

                int read;
                byte[] bytes = new byte[1024];

                while ((read = input.read(bytes)) != -1) {
                    out.write(bytes, 0, read);
                }

                out.close();
                file.deleteOnExit();
            } catch (IOException ex) {
                ex.printStackTrace();
            }
        }

        if (file.exists()) {
            try (FileReader keyReader = new FileReader(file);
                 PemReader pemReader = new PemReader(keyReader))
            {
                PemObject pemObject = pemReader.readPemObject();
                content = pemObject.getContent();
            } catch (IOException e) {
                e.printStackTrace();
            }
        } else {
            throw new Exception("File " + file + " not found");
        }

        return content;
    }

}
  • getAppleInfo(String code)
    • 프론트로부터 전달받은 authorization_code를 통해 사용자 정보를 받아오는 메서드
    • 사용자 정보를 받아오기 위해선 client_secret이 필요
  • createClientSecret()
    • client_secret을 생성하는 메서드
    • JWT로 생성되어야 함
      • header : alg, kid
        • alg :  애플 로그인에 사용될 토큰 서명 알고리즘 ex) ES256
        • yml에서 설정한 key-id 와 동일
      • payload
        • iss : team.id와 동일
        • iat : 토큰의 생성 시간
        • exp : 토큰의 만료 시간
        • aud : client_secret 유효성 검사 서버로 https://appleid.apple.com 을 사용
        • sub : client_id와 동일
  • getPrivateKey()
    • 키파일을 가져오는 메서드
    • APPLE_KEY_PATH는 yml에서 구현해둔 clientSecret 값 사용
    • .p8로 된 key 파일을 가져와야하는데 나는 배포를 한 뒤라 ec2user에 키 파일을 넣어두고 해당 경로를 입력해뒀음!

 

➡️ RestTemplate을 이용해 https://appleid.apple.com/auth/token주소로 데이터를 요청

  • response 안에는 access_token과 id_token 값이 있음
  • id_token 값을 파싱하고(SignedJWT.parse) payload 값을 조회하면 subemail 값을 확인 가능
  • sub값은 사용자 고유값이기 때문에 아이디로 사용했고 이메일을 받아 리턴

 

 

✅ AppleDto

package yerong.baedug.oauth;

import lombok.Builder;
import lombok.Data;

@Builder
@Data
public class AppleDto {

    private String id;
    private String token;
    private String email;
    private String username;
}

 

  • username은 일단 안 받아오는데 혹시 몰라 추가해뒀다... 마음이 바뀔지도...

 

 

 

 

 

 

처음에 무척이나 어질어질하고.. 이해가 안 갔지만

참 많은 에러도 만나고.. 해결해나가면서 흐름을 읽고 조금 이해하게 되었습니다...

아직도 많이 부족하고 완벽하게 이해하지 못 한 거 같아서 다시 한 번 구현해봐야겠어요

그리고 다른 방식으로도 구현해보고 싶다는 생각이 들어여...

 

다른 분들의 코드를 참고해가며 구현하느라 아직 자신은 없지만 .. 

이렇게라도 성공하니까 기분은 좋네여 

앞으로 더 공부해보겠슴댯!!!!!!!!!!!

🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱

 


 

ㅇReference

 

 

 

[Spring Boot]애플 로그인 구현

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

shxrecord.tistory.com

 

[Spring] REST 방식으로 애플 로그인 구현하기 - 2

글이 너무 길어지는거 같아 2부로 나눴다. 1부에서는 애플 개발자계정에서 설정할 수 있는 설정 및 키 파일을 다운로드 받았다. 이제 프로젝트에 적용해보자. 우선 개발자 계정에서 설정한 정보

2bmw3.tistory.com

 

Sign in with Apple REST API | Apple Developer Documentation

Communicate between your app servers and Apple’s authentication servers.

developer.apple.com

 

728x90
LIST