악명이 높기로 소문난 애플 로그인 구현하기
공식 Docs 도 너무 불친절하고 자료도 별로 없어서 애먹었지만
무한한 삽질을 통해 입맛에 맞게 완성시켜 보았다.
엑세스 토큰이 아닌,
OIDC(Open ID Connect)의 id_token 방식을 사용하여 구현했다.
id_token 방식의 경우 이슈어와 앱키 등의 정보가 들어있어
검증 및 로그인 세션을 유지할 수 있게 도와준다.
특히 이는 회원 가입시
OAuth 에서 제공하지 않는 정보의 추가 기입이 필요할 때 유용하게 사용할 수 있다.
📚 Dependency
implementation 'com.nimbusds:nimbus-jose-jwt:3.10'
client secret 을 생성하기 위한 jwt 관련 라이브러리
📌 Apple Developers 설정
애플 로그인은 준비해야 할 것들이 조금 많다....
13만원인가 하는 Apple Developer 서비스를 매년 결제해야 하고
private key 를 발급받은 후 저장한 .p8 파일을 이용하여 시크릿 키를 생성해줘야 하고
또 이것 저것 등록을 해야한다.
근데 이게 IOS 앱 등록 시
타 소셜 로그인을 사용하면 반드시 애플 로그인을 등록해야 해서
거의 반 강제로 구현해야 한다 하하
아니면 리젝 먹인다.
아래 링크를 참고하여 설정하였다.
>> 스프링 프로젝트에 애플 로그인 API 연동을 위한 Apple Developer 설정
🔗 애플 로그인 링크
Query Param 은 다음과 같다
- client_id : Services Id -> identifier 값
- redirect_uri : Return URLs 값, 로그인 직후 리다이렉션 되는 링크
- scope : 전달받을 정보 (name, email, openid)
- response_type : code 로 하면 인가 코드 반환 (고정)
- response_mode : (공백) -> GET, form_post -> 리다이렉션 링크에 POST 요청을 날림
- state : csrf 공격 방지 위한 검증 값
- nonce : 랜덤 값, oauth 인증 토큰 무결성 보호를 위한 것
💦 로그인 요청 시 주의할 점
최초 로그인 요청 링크에서 주의할 점이 몇 가지 있는데
- response_mode 가 form_post 일 때만 scope 를 사용할 수 있다.
- scope 의 name 은 최초 가입시에만 제공되는 필드이다 (이후 요청 시 값이 안나옴)
- redirect_uri 에는 localhost 를 사용할 수 없다. 정책적으로 막혀있다.
ngrok 같은 포워딩 툴로 우회해야 로컬에서 테스트 가능!
위 내용 때문에 삽질하는 경우가 많으니 주의!
결론적으로 나는 로그인 링크를 다음과 같이 구성하였다.
https://appleid.apple.com/auth/authorize?client_id=%s&redirect_uri=%s&response_type=code
🔗 토큰 요청 Feign Client
Feign Client 를 사용중이며 관련 설정은 다음 게시글을 참고하면 되겠다.
📝 AppleOAuthClient
@FeignClient(
name = "AppleOAuthClient",
url = "https://appleid.apple.com",
configuration = AppleOAuthConfig.class)
public interface AppleOAuthClient {
@PostMapping("/auth/token?grant_type=authorization_code")
AppleTokenResponse appleAuth(
@RequestParam("client_id") String clientId,
@RequestParam("redirect_uri") String redirectUri,
@RequestParam("code") String code,
@RequestParam("client_secret") String clientSecret);
}
애플 로그인을 통해 받은 인가 코드를 받아 처리하는 url
여기서 중요한 점은 client_secret 파라미터 인데
p8 파일을 가지고 jwt 인코딩을 해줘서 여기에 집어넣어야 한다.
이 설정을 제대로 해주지 않을 경우
무한 invalid_client 오류에 빠지게 될 것이다......
📝 AppleOAuthHelper
@Component
@RequiredArgsConstructor
public class AppleOAuthHelper {
private final AppleOAuthProperties appleOAuthProperties;
private final AppleOAuthClient appleOAuthClient;
private final AppleOIDCClient appleOIDCClient;
private final OAuthOIDCHelper oAuthOIDCHelper;
//...(생락)
public AppleTokenResponse getOAuthToken(String code, String referer) {
return appleOAuthClient.appleAuth(
appleOAuthProperties.getClientId(),
referer + "callback/apple",
code,
getClientSecret());
}
private String getClientSecret() {
return AppleLoginUtil.createClientSecret(
appleOAuthProperties.getTeamId(),
appleOAuthProperties.getClientId(),
appleOAuthProperties.getKeyId(),
appleOAuthProperties.getKeyPath(),
appleOAuthProperties.getBaseUrl());
}
}
그래서 이 Feign Client 를 호출하기 전에
client secret 을 만드는 로직이 필요하다.
🔨 Client Secret
client secret 을 만드는 방법은 다음 게시글을 기본적으로 참고하였으나
ECPrivateKeyImpl 과 :classpath 읽어오는데 문제가 있어 일부 변형시켰다.
📝 AppleLoginUtil
public class AppleLoginUtil {
/**
* client_secret 생성 Apple Document URL ‣
* https://developer.apple.com/documentation/sign_in_with_apple/generate_and_validate_tokens
*
* @return client_secret(jwt)
*/
public static String createClientSecret(
String teamId, String clientId, String keyId, String keyPath, String authUrl) {
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.ES256).keyID(keyId).build();
JWTClaimsSet claimsSet = new JWTClaimsSet();
Date now = new Date();
claimsSet.setIssuer(teamId);
claimsSet.setIssueTime(now);
claimsSet.setExpirationTime(new Date(now.getTime() + 3600000));
claimsSet.setAudience(authUrl);
claimsSet.setSubject(clientId);
SignedJWT jwt = new SignedJWT(header, claimsSet);
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(readPrivateKey(keyPath));
try {
KeyFactory kf = KeyFactory.getInstance("EC");
ECPrivateKey ecPrivateKey = (ECPrivateKey) kf.generatePrivate(spec);
JWSSigner jwsSigner = new ECDSASigner(ecPrivateKey.getS());
jwt.sign(jwsSigner);
} catch (InvalidKeyException | JOSEException e) {
e.printStackTrace();
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new RuntimeException(e);
}
return jwt.serialize();
}
/**
* 파일에서 private key 획득
*
* @return Private Key
*/
private static byte[] readPrivateKey(String keyPath) {
Resource resource = new ClassPathResource(keyPath);
byte[] content = null;
try (InputStream keyInputStream = resource.getInputStream();
InputStreamReader keyReader = new InputStreamReader(keyInputStream);
PemReader pemReader = new PemReader(keyReader)) {
PemObject pemObject = pemReader.readPemObject();
content = pemObject.getContent();
} catch (IOException e) {
e.printStackTrace();
}
return content;
}
}
Apple Developer 설정할 때 받아왔던 정보를 모두 기억해야 하는 이유이다.
teamId, clientId, keyId 를 모두 요구한다.
여기서 keyPath 는 .p8 파일이 들어있는 폴더이다.
예를 들어 다음과 같이 resources 디렉토리에 p8 파일을 넣어두었다면,
keyPath 에 대한 설정 디렉토리는 다음과 같아진다.
APPLE_KEY_PATH=static/apple/AuthKey_{keyId}.p8
또한 ECPrivateKeyImpl 같은 경우 java.security 에 기본 포함된 것으로 아는데
문제가 있어 비슷한 다른 라이브러리를 차용했다.
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(readPrivateKey(keyPath));
try {
KeyFactory kf = KeyFactory.getInstance("EC");
ECPrivateKey ecPrivateKey = (ECPrivateKey) kf.generatePrivate(spec);
JWSSigner jwsSigner = new ECDSASigner(ecPrivateKey.getS());
jwt.sign(jwsSigner);
} catch (InvalidKeyException | JOSEException e) {
e.printStackTrace();
} catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
throw new RuntimeException(e);
}
그리고 빌드된 jar 파일 내부에서 :classpath 를 제대로 찾지 못해서
FileNotFoundException 이 터지는 문제가 있었다.
getFile 같은 경우
Resource 객체의 참조 리소스가 classpath 에 있는 경우 오류가 발생한다.
반면 getInputStream 은
Resource 객체의 모든 리소스 내용을 바이트 단위로 읽을 수 있고
FileNotFoundException 이 터지지 않는다.
private static byte[] readPrivateKey(String keyPath) {
Resource resource = new ClassPathResource(keyPath);
byte[] content = null;
try (InputStream keyInputStream = resource.getInputStream();
InputStreamReader keyReader = new InputStreamReader(keyInputStream);
PemReader pemReader = new PemReader(keyReader)) {
PemObject pemObject = pemReader.readPemObject();
content = pemObject.getContent();
} catch (IOException e) {
e.printStackTrace();
}
return content;
}
이러한 과정으로 jwt 로 감싸주면 client secret 이 생성된다.
애플 로그인을 처음 구현한다면
다른 OAuth 에 비해 상당히 난해한 부분이 바로 여기이다.
{
"access_token":"a08c1600e80f84d4484...",
"expires_in":3600,
"id_token":"eyJraWQiOiJlWGF1bm...",
"refresh_token":"r8e88bc9f62bc49...",
"token_type":"Bearer"
}
응답값으로 다음과 같이 전달되고
📝 AppleTokenResponse
@Getter
@NoArgsConstructor
@JsonNaming(SnakeCaseStrategy.class)
public class AppleTokenResponse {
private String accessToken;
private String refreshToken;
private String idToken;
}
다음 DTO 에 저장하여 사용한다.
이제 idToken 을 다음 요청에 사용하면 된다.
🔨 id_token 검증하기
메소드와 DTO 등 자세한 것은
내용이 길어져서 게시글을 분리시켰다
🔨 id_token 으로 회원가입하기
📝 AppleController
@PostMapping("/register")
public TokenResponse appleOAuthRegistration(
@RequestParam("id_token") String token,
@Valid @RequestBody UserRegistrationRequest userRegistrationRequest) {
return appleService.registerUserByOCIDToken(token, userRegistrationRequest);
}
발급받은 id token 을 통해 회원가입을 진행해보자.
📝 AppleService
public TokenAndUserResponse registerUserByOCIDToken(
String idToken, UserRegistrationRequest userRegistrationRequest) {
final OAuthInfo oAuthInfo = this.getOAuthInfoByIdToken(idToken);
final UserProfileDto userProfileDto = userRegistrationRequest.toProfile();
final User user = userService.register(userProfileDto, oAuthInfo);
return tokenGenerateHelper.generate(user);
}
Apple OAuth 에서는 유저 정보에 대한 스코프가 현저하게 적기 때문에 (이름, 이메일로 끝)
유저 회원가입 정보를 반드시 추가로 받아야 한다.
이를 받아서 회원가입을 진행하고 토큰을 발급해주는 로직이다.
이 글은 Apple OAuth 에 대한 글이기 때문에 자세한 로직은 생략.
public OAuthInfo getOAuthInfoByIdToken(String idToken) {
OIDCDecodePayload oidcDecodePayload = getOIDCDecodePayload(idToken);
return OAuthInfo.builder().provider(Provider.APPLE).oid(oidcDecodePayload.getSub()).build();
}
public OIDCDecodePayload getOIDCDecodePayload(String token) {
OIDCPublicKeysResponse oidcPublicKeysResponse = appleOIDCClient.getAppleOIDCOpenKeys();
return oAuthOIDCHelper.getPayloadFromIdToken(
token,
appleOAuthProperties.getBaseUrl(),
appleOAuthProperties.getClientId(),
oidcPublicKeysResponse);
}
id token 을 decode 하여 정보를 가져오는 로직
앞서 구현한 id token 검증 로직으로 검증해주면 decode 가 완료된다.
📝 OAuthInfo / Provider
@Getter
@AllArgsConstructor
@Builder
public class OAuthInfo {
private Provider provider;
private String oid;
public static OAuthInfo from(OAuthUserInfoDto oAuthUserInfoDto) {
return new OAuthInfo(oAuthUserInfoDto.getOAuthProvider(), oAuthUserInfoDto.getOauthId());
}
}
@Getter
@AllArgsConstructor
public enum Provider {
GOOGLE("GOOGLE"),
KAKAO("KAKAO"),
APPLE("APPLE");
private String value;
}
OAuth provider 와 유저 고유 아이디인 oid 값을 담는 객체
📝 TokenGenerationHelper
@Service
@RequiredArgsConstructor
public class TokenGenerationHelper {
private final JwtTokenProvider jwtTokenProvider;
private final UserService userService;
@Transactional
public TokenAndUserResponse generate(User user) {
final String newAccessToken =
jwtTokenProvider.generateAccessToken(user.getId(), user.getRole().getValue());
final String newRefreshToken = jwtTokenProvider.generateRefreshToken(user.getId());
user.setRefreshToken(newRefreshToken);
userService.upsert(user);
return TokenAndUserResponse.builder()
.accessToken(newAccessToken)
.accessTokenAge(jwtTokenProvider.getAccessTokenTTLSecond())
.refreshTokenAge(jwtTokenProvider.getRefreshTokenTTLSecond())
.refreshToken(newRefreshToken)
.build();
}
}
최종적으로 엑세스 토큰과 리프레시 토큰까지 발급해주면 완료
🔨 id_token 으로 로그인 하기
📝 AppleController
@PostMapping("/login")
public TokenResponse appleOAuthUserLogin(@RequestParam("id_token") String token) {
return appleService.login(token);
}
이번엔 id token 으로 우리 서버 로그인을 해보자
발급받은 id token 을 가져온다.
📝 AppleService
public TokenResponse login(String idToken) {
OAuthInfo oAuthInfo = this.getOAuthInfoByIdToken(idToken);
User user = userService.login(oAuthInfo);
return tokenGenerationHelper.generate(user);
}
oAuthInfo 를 가지고 유저 정보를 찾음
📝 UserService
@Transactional
public User login(OAuthInfo oAuthInfo) {
User user = userAdaptor.queryByOAuthInfo(oAuthInfo);
user.login();
return user;
}
//User
public void login() {
if (!this.state.equals(ACTIVE)) throw new BaseException(INACTIVE_USER);
}
이후 유저 검증을 해주면 로그인 로직 끝
OIDC 방식과 Apple login 모두를 적용하는게 상당히 난이도 있지만
서버 관련 지식이 더욱 상승하는 것 같다.
다른 OAuth 로그인 대응 시에도 동일하게 사용 가능하니까
한번 적용하면 편리하다.
필자는 실제론 이런 식으로 Provider 별로 Switch 하여 대응 중이다.
private OAuthInfo getOAuthInfoByProviderAndIdToken(Provider provider, String idToken) {
switch (provider) {
case GOOGLE:
return googleOAuthHelper.getOAuthInfoByIdToken(idToken);
case KAKAO:
return kakaoOAuthHelper.getOAuthInfoByIdToken(idToken);
case APPLE:
return appleOAuthHelper.getOAuthInfoByIdToken(idToken);
default:
throw new BaseException(INVALID_OAUTH_PROVIDER);
}
}
'📡 백엔드 > 🌱 Spring Boot' 카테고리의 다른 글
[Spring] Redisson 분산락 AOP로 동시성 문제 해결하기 (트랜잭션 전파속성 NEVER 사용) (0) | 2023.07.06 |
---|---|
[Spring] 스프링 소셜 로그인 OIDC 방식으로 구현하기 (OAuth with OpenID Connect) (1) | 2023.03.25 |
[Spring] 스프링 Feign Client 적용하기 (Spring Cloud OpenFeign) (1) | 2023.03.25 |
[Spring] 스프링 시큐리티 + JWT로 카카오 로그인 구현하기, 프론트엔드와 연결 (0) | 2022.09.04 |
[Spring] OAuth2Service + 스프링 시큐리티 + JWT로 카카오 로그인 구현하기 (0) | 2022.08.27 |