Framework/Spring

[Spring Boot]애플 로그인 구현

  • -
반응형

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

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

애플로그인을 하기 위해서는 AppId, Key, Service ID를 생성해야합니다. 식별자(영문)에서 먼저 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가 생성된 것을 확인할 수 있습니다.

 

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

 

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

Continue 후 등록합니다.

 

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

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

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

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

 

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

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

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

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

 

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

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

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

 

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

  • 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>

 

이제 인증 코드를 전달받을 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에서 확인하실 수 있습니다.

반응형

포스팅 주소를 복사했습니다.

이 글이 도움이 되었다면 공감 부탁드립니다.