오늘은 애플 로그인을 구현하려고한다.
사실 소프트웨어 마에스트로 앱 출시를 위해 애플 스토어 심사를 받았는데, 리젝당했다.. 그 이유는 구글 로그인이 있지만, 애플로그인이 없어서 그렇다,,ㅎㅎ
그래서 오늘은 애플로그인을 구현한걸 포스팅하고자 한다!
Spring Boot, gradle 프로젝트로 진행하였다.
implementation group: 'com.auth0', name: 'java-jwt', version: '3.4.0'
다음과 같이 의존성을 추가해준다. 애플 로그인 토큰을 Decode하려면 필요하다.
먼저 나는 프론트 친구한테 identyToken을 받았다.
identityToken 의 길이가 어마무시하다.. 이걸 헤더로 받아와서 스프링에서 이 헤더를 가지고 애플로그인을 진행하려고한다.
기존의 구글로그인과는 방식이 다르므로 공부해보면 색다로워서 재미있었다.
먼저 다른 블로그들을 보면 Feign을 이용해서 통신했지만, 나는 Spring Cloud의 Feign만을 이용하기 위해서 저 스프링 클라우드의 의존성을 추가하고싶지 않아서, 그냥 RestTemplate를 이용하여 구현했다.
https://appleid.apple.com/auth/keys
제일 첫번째로 위의 링크를 들어가면 json값을 뱉는 것을 알 수 있는데, 저 키가 매번 바뀐다. (바뀌는 주기는 모르겠음)
먼저 저 데이터를 파싱해야한다. 나는 객체로 만들어서 관리했다. (코드의 가독성 증가를 위해)
{
"keys": [
{
"kty":
"kid":
"use":
"alg":
"n":
"e":
},
{
"kty":
"kid":
"use":
"alg":
"n":
"e":
}
]
}
보다 시피 keys 안에 JSONArray로 정보들이 있기에 나는 KeyInfo라는 클래스를 정의했다.
@Getter
@NoArgsConstructor
public class KeyInfo {
private String kty;
private String kid;
private String use;
private String alg;
private String n;
private String e;
public KeyInfo(String kty, String kid, String use, String alg, String n, String e) {
this.kty = kty;
this.kid = kid;
this.use = use;
this.alg = alg;
this.n = n;
this.e = e;
}
public boolean validateKey(String kid, String alg) {
if(this.kid.equals(kid) && this.alg.equals(alg)) return true;
return false;
}
}
그리고 Key클래스를 만들어줬다.
@Getter
@RequiredArgsConstructor
public class Keys {
private List<KeyInfo> keys;
}
자! 이렇게 하면 준비는 끝이 났다. 작성한 코드를 살펴 보겠다.
public ReqSignInMemberDto getAppleMemberInfo(String identityToken) {
try {
RestTemplate restTemplate = new RestTemplate();
Keys keys = restTemplate.getForEntity(APPLE_KEY, Keys.class).getBody();
Map<String, String> headerKey = getTokenHeaderInfo(identityToken);
KeyInfo keyInfo = keys.getKeys().stream().filter(
key -> key.validateKey(headerKey.get(KID), headerKey.get(ALG))).findFirst().orElseThrow( ()-> new CustomException(StatusCode.FORBIDDEN));
PublicKey publicKey = getPublicKey(keyInfo);
Claims memberInfo = Jwts.parser().setSigningKey(publicKey).parseClaimsJws(identityToken).getBody();
JSONObject claims = claimsToJSONObject(memberInfo);
return ReqSignInMemberDto.builder()
.email(claims.get(EMAIL).toString())
.password(claims.get(SUB).toString())
.isOauth(true).build();
} catch (ParseException e) {
throw new CustomException(StatusCode.NOT_FOUND);
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new CustomException(StatusCode.FAILED_SIGNUP);
}
}
단순하게 보면 이 메서드만 호출하는 방식으로 진행했다.
처음에 RestTemplate 객체 생성하고, 아까 위에 말한 주소를 getForEntity 메서드를 통해 저장해줬다. 클래스 지정형식으로 return받아왔다.
그리고 프론트로부터 전달받은 identiyToken의 Header를 분리해야하므로 getTokenHeaderInfo() 메서드를 호출해주었다.
private Map<String, String> getTokenHeaderInfo(String identityToken) throws ParseException {
String[] decodeToken = identityToken.split("\\.");
String headerInfo = new String(Base64.getDecoder().decode(decodeToken[HEADER]));
JSONParser parser = new JSONParser();
JSONObject keyObject = (JSONObject) parser.parse(headerInfo);
Map<String, String> map = new HashMap<>();
map.put(KID, keyObject.get(KID).toString());
map.put(ALG, keyObject.get(ALG).toString());
return map;
}
단순히 jwt토큰을 분리해서 . 으로 구분해주면 0번 인덱스가 header부분 이라는건 다들 알고 있을 것이다. 따라서 헤더를 분리해주었고, JSONParser로 파싱해주었다. 여기서 우리가 필요한 키는 kid와 alg이다.
눈치 챈 사람도 있지만 맨 처음에 애플 링크를 타면 kid와alg가 있다. 프론트로부터 받은 토큰의 헤더의 kid, alg값과 애플에서 제공한 링크에서의 kid와alg가 같은지 유효성 검사를 하기 위해서다.
스트림을 이용해서 같은 키가 있으면 변수에 저장해 주었다.
KeyInfo keyInfo = keys.getKeys().stream().filter(
key -> key.validateKey(headerKey.get(KID), headerKey.get(ALG))).findFirst().orElseThrow( ()-> new CustomException(StatusCode.FORBIDDEN));
Stream에 익숙해지다 보니까 진짜 코드가 너무 깔끔해진 것을 요즘 새삼 느끼고 있다. ㅎㅎ
그 후 Claims 클래스를 이용해서 identityToken의 payLoad부분을 파싱해주려고한다. 위에서 stream으로 kid와 alg가 일치한 데이터를 저장했는데, 그게 곧 PublicKey가 된다.
그럼 이제 Payload 값을 얻을 수 있는데, PublicKey를 얻기위해 다음 메서드를 정의했다.
private PublicKey getPublicKey(KeyInfo keyInfo) throws NoSuchAlgorithmException, InvalidKeySpecException {
byte[] nBytes = Base64.getUrlDecoder().decode(keyInfo.getN());
byte[] eBytes = Base64.getUrlDecoder().decode(keyInfo.getE());
BigInteger n = new BigInteger(1, nBytes);
BigInteger e = new BigInteger(1, eBytes);
RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e);
KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
return keyFactory.generatePublic(publicKeySpec);
}
애플의 Token은 길이가 너무 길어서 BigInteger를 사용해야할 정도다,, 바이트로 변환하고 디코드 해야 안전하게 디코딩이 가능하다!!
해당 n, 과 e값을 RSAPublicKey에 파라미터로 넣어준다. 그 후 RSA 알고리즘을 이용해서 키를 생성해주면 된다.
애플로그인은 구글 로그인과 다르게 'RS256' 즉, SHA-256을 사용하는 RSA(비대칭키 암호화방식)이기 때문에 n(modulus), e(exponent)로 공개키를 구성한다.
그럼 이제 해당 PublicKey로 Claims의 sub값과 email을 이용해서 원하는 데이터를 뽑으면 된다.
애플로그인을 구현하면서 굉장히 재밌었다. 뭔가 CS지식을 직접 몸으로 경험한 느낌이고 보안이 잘 되어있다는 ? 느낌을 받았다.
또한 코드리펙토링을 통해 어떻게 하면 가독성을 증가시킬까? 확장하기 편한 코드로 어떻게 만들까? 고민하면서 코딩을 했더니 좀 재미있었다 ㅎㅎㅎ
[전체 코드]
@Component
public class AppleAuth {
public static final String APPLE_KEY = "https://appleid.apple.com/auth/keys";
public static final int HEADER = 0;
public static String KID = "kid";
public static String ALG = "alg";
public static String ALGORITHM = "RSA";
public static String EMAIL = "email";
public static String SUB = "sub";
public ReqSignInMemberDto getAppleMemberInfo(String identityToken) {
try {
RestTemplate restTemplate = new RestTemplate();
Keys keys = restTemplate.getForEntity(APPLE_KEY, Keys.class).getBody();
Map<String, String> headerKey = getTokenHeaderInfo(identityToken);
KeyInfo keyInfo = keys.getKeys().stream().filter(
key -> key.validateKey(headerKey.get(KID), headerKey.get(ALG))).findFirst().orElseThrow( ()-> new CustomException(StatusCode.FORBIDDEN));
PublicKey publicKey = getPublicKey(keyInfo);
Claims memberInfo = Jwts.parser().setSigningKey(publicKey).parseClaimsJws(identityToken).getBody();
JSONObject claims = claimsToJSONObject(memberInfo);
return ReqSignInMemberDto.builder()
.email(claims.get(EMAIL).toString())
.password(claims.get(SUB).toString())
.isOauth(true).build();
} catch (ParseException e) {
throw new CustomException(StatusCode.NOT_FOUND);
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new CustomException(StatusCode.FAILED_SIGNUP);
}
}
private Map<String, String> getTokenHeaderInfo(String identityToken) throws ParseException {
String[] decodeToken = identityToken.split("\\.");
String headerInfo = new String(Base64.getDecoder().decode(decodeToken[HEADER]));
JSONParser parser = new JSONParser();
JSONObject keyObject = (JSONObject) parser.parse(headerInfo);
Map<String, String> map = new HashMap<>();
map.put(KID, keyObject.get(KID).toString());
map.put(ALG, keyObject.get(ALG).toString());
return map;
}
private PublicKey getPublicKey(KeyInfo keyInfo) throws NoSuchAlgorithmException, InvalidKeySpecException {
byte[] nBytes = Base64.getUrlDecoder().decode(keyInfo.getN());
byte[] eBytes = Base64.getUrlDecoder().decode(keyInfo.getE());
BigInteger n = new BigInteger(1, nBytes);
BigInteger e = new BigInteger(1, eBytes);
RSAPublicKeySpec publicKeySpec = new RSAPublicKeySpec(n, e);
KeyFactory keyFactory = KeyFactory.getInstance(ALGORITHM);
return keyFactory.generatePublic(publicKeySpec);
}
private JSONObject claimsToJSONObject(Claims claims) {
JSONObject jsonObject = new JSONObject();
jsonObject.putAll(claims);
return jsonObject;
}
}
오늘은 여기서 마무리~~
'스프링' 카테고리의 다른 글
Spring ExecutorService 멀티 쓰레드 성능 개선 (Grafana & Prometheus) (0) | 2024.08.17 |
---|---|
[KaKaoLogin RestAPI] oAuth2.0 + SpringBoot - (1) (0) | 2022.08.19 |
[Spring Security] + [JWT] + [RefreshToken] 스프링 시큐리티 JWT 로그인 적용기 (0) | 2022.08.18 |
[Spring Security] + [JWT] 스프링 시큐리티 JWT 로그인[실습] (0) | 2022.08.12 |
[Spring Security] + [JWT] 스프링 시큐리티 JWT 로그인[이론] (1) | 2022.08.12 |