gengminy
갱미니의 코딩일지
gengminy
전체 방문자
오늘
어제
  • 분류 전체보기 (61)
    • 🚀 프로젝트 (16)
      • 🎸 고스락 티켓 (4)
      • 🌞 내친소 (5)
      • 🥁 두둥 (7)
    • 📡 백엔드 (31)
      • 🌱 Spring Boot (13)
      • 🐱 Nest.js (10)
      • ⭐ Node.js (8)
    • 🏭 Infra (11)
      • ⚙ 준비를 위한 준비 (2)
      • 🥑 AWS (3)
      • 🐳 Docker (3)
      • ⚡ Github Actions (3)
    • 🌊 프론트엔드 (1)
      • 🌌 React.js (1)
    • 😎 주저리 (2)

블로그 메뉴

  • 💻Github
  • 📸Instagram
  • ✨Blog

공지사항

인기 글

태그

  • GithubActions
  • nodejs
  • 네스트
  • OAuth
  • SlackAPI
  • nestjs
  • oauth2
  • springboot
  • 스프링
  • github
  • docker
  • 깃헙액션
  • nest
  • JSON
  • 스프링부트
  • 슬랙알림
  • JWT
  • Spring
  • 도커
  • AWS

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
gengminy

갱미니의 코딩일지

[Spring] 스프링 애플 로그인 구현하기 (Sign in with Apple OIDC)
📡 백엔드/🌱 Spring Boot

[Spring] 스프링 애플 로그인 구현하기 (Sign in with Apple OIDC)

2023. 3. 25. 21:58

이 단순한 흑백 버튼 하나에 개발자의 무한한 피와 땀이.... 사과놈들

 

 

악명이 높기로 소문난 애플 로그인 구현하기

 

공식 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 인증 토큰 무결성 보호를 위한 것


💦 로그인 요청 시 주의할 점

최초 로그인 요청 링크에서 주의할 점이 몇 가지 있는데

  1. response_mode 가 form_post 일 때만 scope 를 사용할 수 있다.
  2. scope 의 name 은 최초 가입시에만 제공되는 필드이다 (이후 요청 시 값이 안나옴)
  3. redirect_uri 에는 localhost 를 사용할 수 없다. 정책적으로 막혀있다.
    ngrok 같은 포워딩 툴로 우회해야 로컬에서 테스트 가능!

위 내용 때문에 삽질하는 경우가 많으니 주의!

 

결론적으로 나는 로그인 링크를 다음과 같이 구성하였다.

https://appleid.apple.com/auth/authorize?client_id=%s&redirect_uri=%s&response_type=code

 

 

🔗 토큰 요청 Feign Client

Feign Client 를 사용중이며 관련 설정은 다음 게시글을 참고하면 되겠다.

 

[Spring] 스프링 Feign Client 적용하기 (Spring Cloud OpenFeign)

📌 Feign Client 란? 본래 Netflix (그 넷플릭스 맞음) 에서 오픈 소스 일부로 개발되어 사용중인 경량 REST 클라이언트 현재는 Spring Cloud 프레임워크의 일부가 되었다. 인터페이스로 정의된 API를 기반

gengminy.tistory.com

 

📝 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 읽어오는데 문제가 있어 일부 변형시켰다.

 

>> 스프링 프로젝트에 애플 로그인 API 연동하기

 

 

📝 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 등 자세한 것은

내용이 길어져서 게시글을 분리시켰다

 

[Spring] 스프링 소셜 로그인에 OIDC 사용하기 (OAuth with OpenID Connect)

📌 OpenID Connect (OIDC) 는 무엇인가? OAuth 2.0 프로토콜을 기반으로 한 사용자 인증 프로토콜 accessToken 이외에도 id_token을 사용하여 토큰으로 사용자가 누구인지 확인할 수도 있다. OIDC는 표준 프로토

gengminy.tistory.com

 

 

 

🔨 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
    '📡 백엔드/🌱 Spring Boot' 카테고리의 다른 글
    • [Spring] Redisson 분산락 AOP로 동시성 문제 해결하기 (트랜잭션 전파속성 NEVER 사용)
    • [Spring] 스프링 소셜 로그인 OIDC 방식으로 구현하기 (OAuth with OpenID Connect)
    • [Spring] 스프링 Feign Client 적용하기 (Spring Cloud OpenFeign)
    • [Spring] 스프링 시큐리티 + JWT로 카카오 로그인 구현하기, 프론트엔드와 연결
    gengminy
    gengminy
    코딩

    티스토리툴바