[Spring Boot]애플 로그인 구현
앱스토어 배포시에 애플 로그인이 필요하다는 말에 개발을 하게 됐었는데 구현이 다른 소셜 로그인에 비해 꽤나 복잡했었습니다. 언젠가 또 개발할 일이 있지 않을까라는 생각에 기록을 남겨봅니다. 애플 로그인은 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) 인증 코드 요청
애플 개발자 문서에서는 인증 코드 요청하는 방법을 아래와 같이 소개합니다.
- 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 값을 조회하면 sub와 email 값을 확인할 수 있습니다. sub값은 사용자 고유값이기 때문에 아이디로 사용했고 이메일을 받아 리턴하도록 했습니다.
response 값과 id_token을 이용해 얻은 사용자 정보를 출력해보면 아래와 같이 조회됩니다.
2-3) DTO 생성
@Builder
@Data
public class AppleDTO {
private String id;
private String token;
private String email;
}
2-4) 결과
*로그인 해제는 여기서 할 수 있습니다.
참고자료
+ 피드백은 언제나 환영입니다 :)
본 예제에서는 팀 아이디를 제외한 나머지 값들은 현재 존재하지 않는 값들이기에 오픈했습니다. 하지만 팀에서 관리될 때는 절대 외부로 노출되어서는 안됩니다.
전체 소스는 GitHub에서 확인하실 수 있습니다.