[Spring boot][Apple Login] 애플로그인 구현
![](https://t1.daumcdn.net/keditor/emoticon/friends1/large/017.gif)
매우 악명이 높은 애플로그인을 ..... 구현해보았습니다......
제가 이해한 애플로그인의 방법은
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를 입력했어야했던것 !!!.,,,... 그렇게 했더니 바로 성공 ㅠㅠ 기쁨의 눈물을 흘렸다....
![](https://t1.daumcdn.net/keditor/emoticon/friends1/large/029.gif)
✅ 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/ 여기서 로그인 해제를 통해 다시 받아오면 된다
✅ 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와 동일
- header : alg, kid
- 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 값을 조회하면 sub와 email 값을 확인 가능
- 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은 일단 안 받아오는데 혹시 몰라 추가해뒀다... 마음이 바뀔지도...
![](https://t1.daumcdn.net/keditor/emoticon/friends1/large/021.gif)
처음에 무척이나 어질어질하고.. 이해가 안 갔지만
참 많은 에러도 만나고.. 해결해나가면서 흐름을 읽고 조금 이해하게 되었습니다...
아직도 많이 부족하고 완벽하게 이해하지 못 한 거 같아서 다시 한 번 구현해봐야겠어요
그리고 다른 방식으로도 구현해보고 싶다는 생각이 들어여...
다른 분들의 코드를 참고해가며 구현하느라 아직 자신은 없지만 ..
이렇게라도 성공하니까 기분은 좋네여
앞으로 더 공부해보겠슴댯!!!!!!!!!!!
🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱🌱
ㅇ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