Framework/Spring

[Spring Boot]애플 로그인 구현

SHXL2 2023. 3. 21. 17:49
반응형

앱스토어 배포시에 애플 로그인이 필요하다는 말에 개발을 하게 됐었는데 구현이 다른 소셜 로그인에 비해 꽤나 복잡했었습니다. 언젠가 또 개발할 일이 있지 않을까라는 생각에 기록을 남겨봅니다. 애플 로그인은 localhost 테스트가 불가능하기 때문에 도메인을 준비 후(https) 테스트해야 합니다.

※ 테스트 환경
  • Spring Boot
  • JDK 1.8
  • Gradle
  • Thymeleaf

1. 애플 개발자 설정

1) 애플 개발자 사이트에 접속 후 Account 메뉴로 들어갑니다.

2) 로그인에 필요한 인증서 생성

애플로그인을 하기 위해서는 AppId, Key, Service ID를 생성해야합니다. 식별자(영문)에서 먼저 AppId를 만들어보겠습니다.

 

3) AppID 생성

3-1) Identifiers에서 + 버튼을 클릭합니다.

 

3-2) App IDs 선택후 Continue

 

3-3) App 선택 후 Continue

 

3-4) Description & Bundle ID 입력 후 하단에 Sign In with Apple을 클릭하고 Continue합니다.

  • Description : 해당 AppID 설명이 가능하도록 입력합니다.
  • Bundle ID : 도메인(또는 프로젝트 패키지)의 역순을 사용합니다.

 

 

3-5) 등록

등록 후에는 아래처럼 새로운 AppId가 생성된 것을 확인할 수 있습니다.

 

4) Service ID 생성

다시 + 버튼을 클릭합니다.

 

Services IDs 선택 후 Description & Identifier 를 입력해줍니다. 위와 동일하게 Description에는 Service ID를 설명할 수 있는 내용을 적으면 되고 Identifier는 패키지의 역순으로 작성합니다. 위에서 도메인의 역순을 작성했다면 중복 입력이 불가하기 때문입니다.

Continue 후 등록합니다.

 

5) Key 생성

5-1) 다시 메뉴로 돌아와 Keys 메뉴의 + 버튼을 클릭합니다.

5-2) 키 이름 입력과 Sign in with Apple을 선택해줍니다.

Continue 후에 3)에서 생성한 App ID를 선택합니다.

저장 후 등록하게 되면 새로운 키 ID가 생성되고 다운로드가 가능합니다. 생성된 키는 재다운이 불가능하니 잘 보관해야합니다.

 

6) 리다이렉트 URL 등록

6-1) 다시 메뉴로 돌아와 Service ID를 조회합니다.

6-2) Configure 를 통해 접근 가능 도메인과 리다이렉트 URL을 등록합니다.

Domains and Subdomains : https 프로토콜을 사용하는 도메인을 입력해야합니다. localhost는 등록되지 않습니다. https를 제외한 도메인을 입력하고 서브 도메인을 사용하는 경우 추가로 입력이 가능합니다.

Return URLs : 리다이렉트할 전체 주소를 입력합니다.

 

2. 로그인 구현

애플 로그인은 위에 설정을 해보면 알 수 있듯 다른 소셜 로그인에 비해 구현이 복잡한 편입니다. 이 예제에서는 email, sub(고유 식별 번호) 만을 추출해서 사용하게 됩니다.

애플 로그인을 위해서는 크게 두가지 과정을 거치게 됩니다.

  1. 인증 코드 요청
  2. 액세스 토큰 및 유저 정보 요청

 

1) 인증 코드 요청

애플 개발자 문서에서는 인증 코드 요청하는 방법을 아래와 같이 소개합니다.

  • client_id : (필수) 1.1-4) 에서 생성한 Service ID의 identifier 값을 의미합니다.
  • redirect_uri : (필수) 1.6-2)에서 등록한 redirect URI 입니다.
  • response_type : (필수) 응답 값을 뜻하는데 code 나 code와 id_token 값만 지정가능합니다. code 는 액세스 토큰 호출 시 사용됩니다.
  • scope : 사용자 정보 요청값을 의미하고 name과 email만 가능합니다. 이 때 공백을 구분으로 합니다.
  • response_mode : 응답 유형을 뜻하는데 쿼리 파람형식 또는 form post 유형 지정을 합니다.
  • state : 기타 파라미터 사용시 사용할 수 있습니다.

*nonce 는 실제로 사용을 해보진 않아서 어떻게 사용되는지 정확하지 않지만 ID 토큰값의 보안을 강화할 때 사용하는 값이 아닐까 싶네요... 본 예제에서는 제외되었으니 참고해주세요.

 

1-1) application.properties 정의

apple.team.id=애플 개발자 팀아이디
apple.login.key=9TCLGUF88R
apple.client.id=sociallogin.app.com
apple.redirect.url=https://wenail.co.kr/apple/callback
apple.key.path=/key/AuthKey_9TCLGUF88R.p8

  • apple.team_id : 애플 개발자 사이트 상단에 보면 빨간박스에는 그룹이름이 보이고 파란박스에는 특정코드가 보이는데 이 코드가 팀 아이디가 됩니다.
  • apple.login.key : 1.1-5) 에서 생성된 키 ID 
  • apple.key.path : 1.1-5) 키 생성 후 다운로드 받은 키값의 위치입니다. 이 프로젝트에서는 /resources/key에 위치합니다.

나머지 값은 위에서 설명한 것 과 동일합니다.

 

1-2) MsgEntity 정의

로그인 화면 호출시 뷰를 담당하는 컨트롤러에서 인증 URL을 모델에 담아 화면에 전달하고, 로그인시 redirect_uri을 통해 넘어오는 컨트롤러는 @RestController로 json 데이터를 응답하게 할 것입니다. 이 때, MsgEntity에 정의된 키값으로 데이터를 보내려 합니다.

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

    private String id;
    private Object result;

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

 

1-3) Controller 생성

@RequiredArgsConstructor
@Controller
public class HomeController {

    private final AppleService appleService;

    @RequestMapping(value="/", method= RequestMethod.GET)
    public String login(Model model) {
        model.addAttribute("appleUrl", appleService.getAppleLogin());

        return "index";
    }
}

 

1-4) Service 생성

@RequiredArgsConstructor
@Service
public class AppleService {

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

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

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

    @Value("${apple.redirect.url}")
    private String APPLE_REDIRECT_URL;

    @Value("${apple.key.path}")
    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";
    }
}

 

1-5) index.html 생성

<!DOCTYPE html>
<html lang="ko" xmlns:th="http://www.thymeleaf.org">
<head>
    <title>소셜로그인 예제</title>
</head>
<body>
<div>
    <p><a th:href="@{|${appleUrl}|}">애플로그인</a></p>
</div>
</body>
</html>

 

2) 액세스 토큰 및 유저 정보 요청

이제 인증 코드를 전달받을 redirect_uri에 매핑될 Controller를 만들어보겠습니다.

 

2-1) Controller 생성

@RestController
@RequiredArgsConstructor
@RequestMapping("apple")
public class AppleController {

    private final AppleService appleService;

    @PostMapping("/callback")
    public ResponseEntity<MsgEntity> callback(HttpServletRequest request) throws Exception {
        AppleDTO appleInfo = appleService.getAppleInfo(request.getParameter("code"));

        return ResponseEntity.ok()
                .body(new MsgEntity("Success", appleInfo));
    }
}

2.1) 까지 진행 후 로그인을 하게 되면 아래와 같은 화면을 볼 수 있고 로그인이 성공하게 되면 redirect_uri("/apple/callback")로 데이터가 리턴됩니다.

이 때 response user 키안에는 사용자명이 담겨있는데 이 값은 최초 1회만 받게 됩니다. 이후에는 로그인 해제를 하고 다시 받을 수 있습니다. 사용자 이름이 필요하다면  참고하세요.

 

2-2) Service 수정

@RequiredArgsConstructor
@Service
public class AppleService {

   ...

    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) {
			throw new Exception("API call failed");
		}

        return AppleDTO.builder()
                .id(userId)
                .token(accessToken)
                .email(email).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);

        try {
            ECPrivateKey ecPrivateKey = new ECPrivateKeyImpl(getPrivateKey());
            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 = null;

        URL res = getClass().getResource(APPLE_KEY_PATH);

        if ("jar".equals(res.getProtocol())) {
            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();
            }
        } else {
            file = new File(res.getFile());
        }

        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;
    }

}

서비스에 추가된 메소드는 총 3개입니다.

getAppleInfo(String code)
createClientSecret()
getPrivateKey()

getAppleInfo(String code)는 컨트롤러에서 리턴받은 인증 코드값을 통해 사용자 정보를 리턴하게 됩니다. 여기서 사용자 정보를 가져오기 위해 client_secret 이라는 값이 필요한데 이 값을 만들기 위해 사용되는 메소드가 createClientSecret()입니다.

 

client_secret은 JWT로 생성되어야 하는데 이 때 JWT header와 payload에 포함되어야하는 값은 아래와 같습니다. (애플 개발자 Create the client secret 참고)
-header

  • alg : 애플 로그인에 사용될 토큰 서명 알고리즘을 입력합니다. ES256
  • kid : 2.1-1)에서 설정한 apple.login.key와 동일합니다.
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(APPLE_LOGIN_KEY).build();

-payload

  • iss : 2.1-1)에서 설정한 team.id와 동일합니다.
  • iat : 토큰의 생성 시간을 의미합니다.
  • exp : 토큰의 만료 시간을 의미합니다.
  • aud : client_secret 유효성 검사 서버로 https://appleid.apple.com 을 사용합니다.
  • sub : 2.1-1)에서 설정한 client.id와 동일합니다.
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);

설정한 header와 payload로 JWT를 작성하고 서명(Signature)을 해야하는데 이 때 사용되는 것이 1.5에서 생성한 Key 파일입니다. getPrivateKey()를 통해 키 파일을 가지고 오게 되어있습니다.

 

해당 키는 resources 경로에 넣어두었는데 jar 파일로 배포를 하고 java -jar로 빌드를 하게 되면 키 파일의 경로를 잡지 못하게 됩니다. 배포한 jar 파일에 대한 루트 경로가 jar:file:/.../xxx.jar!/static/...와 같이 표시되기 때문에 경로가 맞지 않아 에러가 발생합니다. 그래서 배포시에는 jar:// 로 시작하는 주소를 체크해 따로 파일을 읽어들일 수 있도록 처리해주었습니다.

 

이제 설정은 완료됐습니다. RestTemplate을 이용해 https://appleid.apple.com/auth/token 주소로 데이터를 요청해봅니다. 

 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"));

response 안에는 access_token과 id_token 값이 존재합니다.

id_token 값을 파싱하고(SignedJWT.parse) payload 값을 조회하면 subemail 값을 확인할 수 있습니다. sub값은 사용자 고유값이기 때문에 아이디로 사용했고 이메일을 받아 리턴하도록 했습니다.

response 값과 id_token을 이용해 얻은 사용자 정보를 출력해보면 아래와 같이 조회됩니다.

 

2-3) DTO 생성

@Builder
@Data
public class AppleDTO {

    private String id;
    private String token;
    private String email;

}

 

2-4) 결과

 

 

*로그인 해제는 여기서 할 수 있습니다.

 

참고자료
 

[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


+ 피드백은 언제나 환영입니다 :)

본 예제에서는 팀 아이디를 제외한 나머지 값들은 현재 존재하지 않는 값들이기에 오픈했습니다. 하지만 팀에서 관리될 때는 절대 외부로 노출되어서는 안됩니다.

 

전체 소스는 GitHub에서 확인하실 수 있습니다.

반응형