~☆~ 우하하!!~ 개발블로그

[SpringBoot][소셜로그인] Apple 계정으로 로그인하기 본문

SpringBoot

[SpringBoot][소셜로그인] Apple 계정으로 로그인하기

iwoohaha 2024. 11. 25. 14:46
반응형

이번에는 SpringBoot 프로젝트에서 Apple 계정으로 로그인하기를 구현해보려고 한다.

Apple 에서 제공하는 Sign in with Apple (애플 계정으로 로그인) 기능을 사용하기 위해서는 애플 개발자 계정을 보유하고 있어야 하는데, 매년 십만원 넘게 결제해야 하는 부담이 있다.

애플 개발자 계정으로 애플 개발자 센터(https://developer.apple.com/)에 로그인해서 설정해야 할 내용은 https://iwoohaha.tistory.com/345 글을 참고하기 바란다.

SpringBoot 프로젝트의 login.html 파일에 Apple 버튼을 추가한다.

...
    .css-1ti50tg {
         opacity: 1;
         transition: opacity 0.3s;
         display: flex;
         margin: 0px 2px;
         -webkit-box-flex: 0;
         flex-grow: 0;
         flex-shrink: 0;
         align-self: center;
         font-size: 0px;
         line-height: 0;
         user-select: none;
         margin-inline-start: var(--ds-space-negative-025, -2px);
    }
    .css-apple-button {
         -webkit-box-align: baseline;
         align-items: baseline;
         box-sizing: border-box;
         display: inline-flex;
         font-size: inherit;
         font-style: normal;
         font-family: inherit;
         max-width: 100%;
         position: relative;
         text-align: center;
         text-decoration: none;
         transition: background 0.1s ease-out, box-shadow 0.15s cubic-bezier(0.47, 0.03, 0.49, 1.38);
         white-space: nowrap;
         cursor: pointer;
         padding: 0px 10px;
         vertical-align: middle;
         width: 100%;
         -webkit-box-pack: center;
         justify-content: center;
         box-shadow: none;
         font-weight: bold;
         border: 1px solid rgb(193, 199, 208);
         border-radius: 3px;
         color: var(--ds-text, #42526E) !important;
         height: 40px !important;
         line-height: 40px !important;
         background: rgb(255, 255, 255) !important;
    }

...
                    <div class="css-1vymulm">
                        <a href="/oauth2/authorization/apple">

                            <button id="apple-auth-button" class="css-apple-button" tabindex="0" type="button">
                                <span class="css-1ti50tg">
                                    <img src="/images/applelogo.svg" alt="Apple Logo" style="width: 24px; height: 24px; margin-right: 10px;">
                                </span>
                                <span>Apple</span>
                            </button>

                        </a>
                    </div>
...

그 결과 화면은 아래의 모습이다.

https://iwoohaha.tistory.com/347 에서 설명하는 ngrok 이나 https://iwoohaha.tistory.com/348 에서 설명하는 cloudflare tunnel 을 이용해서 외부에 로컬 프로젝트가 공개되도록 설정한다(이 포스트에서는 cloudflare tunnel 을 이용하는 방법으로 설명한다).

가장 먼저 application.yml 에 Apple 의 Sign in with Apple 기능을 사용하기 위한 설정을 추가하도록 하자.

spring:
  security:
    oauth2:
      client:
        registration:
          apple:
            client-id: com.woohahaapps.diary.signin          # Apple Developer Console에서 발급된 Client ID
            client-name: Apple
            client-authentication-method: client_secret_post # 기본값인 post를 지원하지 않으므로 명시적으로 설정
            authorization-grant-type: authorization_code
            redirect-uri: "https://apple-login.woohahaapps.com/login/oauth2/code/apple" # 인증 성공 후 리디렉션 URL
            scope:
              - email
              - name
        provider:
          apple:
            authorization-uri: https://appleid.apple.com/auth/authorize?response_mode=form_post
            token-uri: https://appleid.apple.com/auth/token
...

여기까지 수행한 상태에서 프로젝트를 실행시켜서 Apple 버튼을 클릭하면 아래 화면과 같이 Apple 사이트로 이동하여 애플 계정 아이디를 입력할 수 있다.

해당 기기에서 처음 로그인하는 경우라면 이중 인증 화면이 표시가 될 수도 있다.

위 화면에서 "계속" 버튼을 클릭하면 로그인 화면으로 되돌아온다. 아직 Apple 계정으로 로그인하기 위한 데이터를 처리할 코드를 아무것도 구현하지 않았기 때문이다.

Apple 버튼을 클릭해서 Apple 사이트에서 애플 계정으로 로그인을 완료하고 "계속" 버튼을 누르면 Redirect URI 정보로 설정한 URL(https://apple-login.woohahaapps.com/login/oauth2/code/apple) 이 호출된다. 이 URL 은 POST 방식으로 호출되므로 함께 전달되는 데이터를 처리하기 위해서는 처리 클래스가 필요하다.

src/main/java/com.woohahaapps.study.diary/oauth2/apple 패키지 아래에 CustomRequestEntityConverter 라는 이름으로 클래스 파일을 생성하고 아래 코드를 입력한다.

package com.woohahaapps.study.diary.oauth2.apple;

import org.springframework.http.RequestEntity;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter;
import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap;

import java.net.URI;

public class CustomRequestEntityConverter extends OAuth2AuthorizationCodeGrantRequestEntityConverter {

    @Override
    public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest request) {
        // 기존 요청을 생성
        RequestEntity<?> originalRequest = super.convert(request);

        // Apple 요청이 아니면 기본 요청 반환
        return originalRequest;
    }
}

SecurityConfig.java 에서 CustomRequestEntityConverter 클래스를 사용하도록 설정한다.

...
                .oauth2Login(oauth2Login ->
                        oauth2Login
                                .loginPage("/login")
                                .tokenEndpoint(token -> token
                                        .accessTokenResponseClient(customAccessTokenResponseClient())
                                )
                                .userInfoEndpoint(userInfo -> userInfo
                                        .userService(customOAuth2UserService))
                                .successHandler(customOAuth2LoginSuccessHandler)
                )
...
    private DefaultAuthorizationCodeTokenResponseClient customAccessTokenResponseClient() {
        DefaultAuthorizationCodeTokenResponseClient client = new DefaultAuthorizationCodeTokenResponseClient();
        client.setRequestEntityConverter(new CustomRequestEntityConverter());
        return client;
    }
...

추가된 설정은 위 코드에서 .tokenEndpoint 부분이다. 이 안에서 사용하는 customAccessTokenResponseClient 함수에서 CustomRequestEntityConverter 클래스를 사용하고 있다.

여기까지 작성한 후에 CustomRequestEntityConverter 클래스에서 return originalRequest; 라인에 BreakPoint 를 걸고 디버그모드로 실행하여 보자.

Apple 계정 로그인 후 "계속" 버튼을 클릭했을 때 BreakPoint 에서 멈추게 되고, 그 상황에서 originalRequest 의 내용은 다음과 같다.

이 흐름은 Apple 에서 애플 계정을 통한 로그인 성공 후 Authorization Code 를 /login/oauth2/code/apple 로 설정된 Redirect URI 로 보내주는 것인데, 클라이언트는 애플이 보내준 Authorization Code 를 사용하여 Access Token 을 요청하는 작업을 수행해야 한다. 이 때 클라이언트의 요청은 https://appleid.apple.com/auth/token 으로 전송된다.

단, 요청을 전송하기 전에 Sign in with Apple 기능에 사용되는 p8 확장자의 key 파일을 사용하여 client_secret 을 만들어서 채워주어야 한다.

그리고, SecurityConfig 에서 CustomRequestEntityConverter 를 사용하도록 설정하는 순간, Apple 뿐만 아니라, 다른 소셜로그인에서도 이 함수로 진입되므로 지금의 로직은 clientRegistration의 registrationId 가 "apple" 인 경우에만 처리되도록 해야 한다. 이 부분이 Apple 과 다른 소셜 로그인과의 흐름 차이이다.

package com.woohahaapps.study.diary.oauth2.apple;

import org.springframework.http.RequestEntity;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter;
import org.springframework.util.MultiValueMap;

public class CustomRequestEntityConverter extends OAuth2AuthorizationCodeGrantRequestEntityConverter {

    @Override
    public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest request) {
        // 기존 요청을 생성
        RequestEntity<?> originalRequest = super.convert(request);

        String registrationId = request.getClientRegistration().getRegistrationId();
        if (registrationId.equals("apple")) {
            MultiValueMap<String, String> body = (MultiValueMap<String, String>) originalRequest.getBody();
            // client_secret 채우기
            body.set("client_secret", generateClientSecret());

            return RequestEntity
                    .post(request.getClientRegistration().getProviderDetails().getTokenUri())
                    .body(body);
        }

        return originalRequest;
    }

    private String generateClientSecret() {
        return "";
    }
}

위 코드에서 아직 완성되지 않은 generateClientSecret 함수는 애플 개발자 계정에서 내려받은 Key 파일(.p8)을 읽어서 jwt 토큰 문자열로 작성하는 기능을 구현하면 된다.

Key 파일을 /var/data/AuthKey.p8 과 같이 절대 경로에 저장해 둔 상태라고 가정한다(보안상 이 Key 파일이 SpringBoot 프로젝트에 포함될 이유가 없다).

Key 파일을 읽어서 jwt 토큰값을 생성하는 기능의 서비스 파일 AppleClientSecretService 클래스를 아래와 같이 작성한다.

package com.woohahaapps.study.diary.service;

import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.SignatureAlgorithm;
import org.springframework.stereotype.Service;

import java.nio.file.Files;
import java.nio.file.Paths;
import java.security.KeyFactory;
import java.security.PrivateKey;
import java.security.spec.PKCS8EncodedKeySpec;
import java.util.Base64;
import java.util.Date;

@Service
public class AppleClientSecretService {
    private final String teamId = "82YP86DL7C";
    private final String clientId = "com.woohahaapps.diary.signin";
    private final String keyId = "LCN8P6YW4C";

    public String generateClientSecret() {
        try {
            // Private Key 로드
            PrivateKey privateKey = privateKey();
            // JWT 생성
            return Jwts.builder()
                    .setHeaderParam("kid", "LCN8P6YW4C") // Key ID
                    .setIssuer("82YP86DL7C") // Team ID
                    .setAudience("https://appleid.apple.com") // Apple 고정 값
                    .setSubject("com.woohahaapps.diary.signin") // Client ID
                    .setExpiration(new Date(System.currentTimeMillis() + 3600 * 1000)) // 만료 시간
                    .signWith(SignatureAlgorithm.ES256, (java.security.interfaces.ECPrivateKey) privateKey)
                    .compact();
        } catch (Exception e) {
            throw new RuntimeException("Failed to generate Apple client secret", e);
        }
    }

    private PrivateKey privateKey() {
        try {
            // Apple에서 발급한 .p8 파일 경로
            String path = "/var/data/AuthKey.p8";
            System.out.println("Loading private key from: " + path);

            // 키 파일 읽기
            String privateKeyContent = Files.readString(Paths.get(path));
            System.out.println("Raw private key content: " + privateKeyContent);

            // BEGIN/END 제거 및 Base64 디코딩
            privateKeyContent = privateKeyContent
                    .replaceAll("\\n", "")
                    .replace("-----BEGIN PRIVATE KEY-----", "")
                    .replace("-----END PRIVATE KEY-----", "");
            byte[] keyBytes = Base64.getDecoder().decode(privateKeyContent);

            // Elliptic Curve (EC) KeyFactory 사용
            KeyFactory keyFactory = KeyFactory.getInstance("EC");
            return keyFactory.generatePrivate(new PKCS8EncodedKeySpec(keyBytes));
        } catch (Exception e) {
            e.printStackTrace();
            throw new RuntimeException("Failed to load private key", e);
        }
    }
}

CustomRequestEntityConverter 클래스에서 generateClientSecret 함수를 사용하는 대신 AppleClientSecretService 를 이용하는 방식으로 변경해보자.

package com.woohahaapps.study.diary.oauth2.apple;

import com.woohahaapps.study.diary.service.AppleClientSecretService;
import org.springframework.http.RequestEntity;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest;
import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequestEntityConverter;
import org.springframework.util.MultiValueMap;

public class CustomRequestEntityConverter extends OAuth2AuthorizationCodeGrantRequestEntityConverter {

    // AppleClientSecretService 의존성 주입
    private final AppleClientSecretService appleClientSecretService;

    public CustomRequestEntityConverter(AppleClientSecretService appleClientSecretService) {
        this.appleClientSecretService = appleClientSecretService;
    }

    @Override
    public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest request) {
        // 기존 요청을 생성
        RequestEntity<?> originalRequest = super.convert(request);

        String registrationId = request.getClientRegistration().getRegistrationId();
        if (registrationId.equals("apple")) {
            MultiValueMap<String, String> body = (MultiValueMap<String, String>) originalRequest.getBody();
            // client_secret 채우기
            body.set("client_secret", appleClientSecretService.generateClientSecret());

            return RequestEntity
                    .post(request.getClientRegistration().getProviderDetails().getTokenUri())
                    .body(body);
        }

        return originalRequest;
    }

}

이제 CustomRequestEntityConverter 클래스 생성시 파라미터가 필요하게 되었다. 따라서 SecurityConfig 를 다음과 같이 수정해야 한다.

...
    @Bean
    public SecurityFilterChain filterChain(HttpSecurity httpSecurity
        , AppleClientSecretService appleClientSecretService) throws Exception {
...
                .oauth2Login(oauth2Login ->
                        oauth2Login
                                .loginPage("/login")
                                .tokenEndpoint(token -> token
                                        .accessTokenResponseClient(customAccessTokenResponseClient(appleClientSecretService))
                                )
                                .userInfoEndpoint(userInfo -> userInfo
                                        .userService(customOAuth2UserService))
                                .successHandler(customOAuth2LoginSuccessHandler)
                )
...
    private DefaultAuthorizationCodeTokenResponseClient customAccessTokenResponseClient(AppleClientSecretService appleClientSecretService) {
        DefaultAuthorizationCodeTokenResponseClient client = new DefaultAuthorizationCodeTokenResponseClient();
        client.setRequestEntityConverter(new CustomRequestEntityConverter(appleClientSecretService));
        return client;
    }
}

여기까지 작성해놓고 프로젝트를 빌드하여 실행시키면 CustomOAuth2UserService 의 loadUser 함수에서 Exception 이 발생하게 된다.

그 이유는 Apple OAuth2 가 Google, Facebook 과 달리 사용자 정보를 가져오는 UserInfo Endpoint 를 제공하지 않기 때문이다. 따라서 loadUser 함수에서 super.loadUser 를 호출하기 전에 registrationId 값을 확인하여 "apple" 이면 별도의 로직으로 흐르게 만들어야 한다.

수정된 loadUser 함수는 다음과 같다.

...
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        String provider = userRequest.getClientRegistration().getRegistrationId();
        System.out.printf("provider = [%s]%n", provider);
        // 각 Provider에 따라 nameAttributeKey 설정
        String nameAttributeKey = userRequest.getClientRegistration()
                .getProviderDetails()
                .getUserInfoEndpoint()
                .getUserNameAttributeName();

        OAuth2User oAuth2User = null;
        OAuth2UserInfo oAuth2UserInfo = null;

        if (provider.equals("apple")) {
            Map<String, Object> claims = userRequest.getAdditionalParameters();
            if (claims.containsKey("id_token")) {
                String idToken = (String) claims.get("id_token");
                Map<String, Object> extractedUserInfo = extractUserInfoFromIdToken(idToken);
                System.out.println("Extracted User Info: " + extractedUserInfo);
                oAuth2UserInfo = new AppleOAuth2UserInfo(extractedUserInfo);

                // `oAuth2User`를 수동으로 생성
                Collection<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_USER"));
                nameAttributeKey = "sub";
                oAuth2User = new DefaultOAuth2User(authorities, extractedUserInfo, nameAttributeKey);
            }
        } else {
            oAuth2User = super.loadUser(userRequest);
            if (provider.equals("google")) {
                oAuth2UserInfo = new GoogleOAuth2UserInfo(oAuth2User.getAttributes());
            } else if (provider.equals("naver")) {
                oAuth2UserInfo = new NaverOAuth2UserInfo(oAuth2User.getAttribute("response"));
            } else if (provider.equals("kakao")) {
                oAuth2UserInfo = new KakaoOAuth2UserInfo(oAuth2User.getAttributes());
            } else if (provider.equals("facebook")) {
                oAuth2UserInfo = new FacebookOAuth2UserInfo(oAuth2User.getAttributes());
            }
        }
        String email = oAuth2UserInfo.getEmail();
        String name = oAuth2UserInfo.getName();
        String provider_user_id = oAuth2UserInfo.getProviderId();
        String profile_image_url = oAuth2UserInfo.getProfileImageUrl();

        Optional<Users> users = usersMapper.GetUsers(provider_user_id, provider);
        if (users.isEmpty()) { // users 테이블에 OAuth2 인증정보가 존재하지 않음
            Users newUsers = new Users();
            newUsers.setProvider(provider);
            newUsers.setProvider_user_id(provider_user_id);
            newUsers.setEmail(email);
            newUsers.setName(name);
            newUsers.setProfile_image_url(profile_image_url);

            Integer affectedCount = usersMapper.CreateUsers(newUsers);
            if (affectedCount < 1) {
                throw new RuntimeException(String.format("""
                                소셜 로그인 인증정보 저장오류: 
                                provider=%s
                                , provider_user_id=%s
                                , email=%s
                                , name=%s
                                , profile_image_url=%s
                                """
                                , provider, provider_user_id, email, name, profile_image_url
                ));
            }
            // 소셜 로그인 인증정보 저장 성공
            users = usersMapper.GetUsers(provider_user_id, provider);
        }

        // 기존 attributes를 복사하여 새로운 HashMap 생성
        Map<String, Object> attributes = new HashMap<>(oAuth2User.getAttributes());

        // usersAttributes 추가
        Map<String, Object> usersAttributes = oAuth2UserInfo.convertToMap();
        usersAttributes.put("uid", users.get().getId());

        attributes.put("users", usersAttributes);

        Collection<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_USER"));

        return new DefaultOAuth2User(authorities, attributes, nameAttributeKey);
    }

    private Map<String, Object> extractUserInfoFromIdToken(String idToken) {
        // Nimbus JWT 라이브러리로 id_token을 파싱 및 사용자 정보 추출
        // (예: name, email 등)
        return AppleIdTokenParser.parseIdToken(idToken); // 사용자 정보 반환
    }
}

위 소스코드에 추가된 AppleOAuth2UserInfo 클래스와 AppleIdTokenParser 클래스는 다음과 같다.

package com.woohahaapps.study.diary.domain;

import java.util.HashMap;
import java.util.Map;

public class AppleOAuth2UserInfo implements OAuth2UserInfo {
    private final Map<String, Object> attributes;

    public AppleOAuth2UserInfo(Map<String, Object> attributes) {
        this.attributes = attributes;
    }

    @Override
    public String getProvider() {
        return "apple";
    }

    @Override
    public String getProviderId() {
        return (String) attributes.get("sub");
    }

    @Override
    public String getEmail() {
        return (String) attributes.get("email");
    }

    @Override
    public String getName() {
        return "";
    }

    @Override
    public String getProfileImageUrl() {
        return "";
    }

    @Override
    public Map<String, Object> convertToMap() {
        Map<String, Object> map = new HashMap<>();
        map.put("provider", getProvider());
        map.put("providerid", getProviderId());
        map.put("email", getEmail());
        map.put("name", getName());
        map.put("profileimageurl", getProfileImageUrl());
        return map;
    }
}
package com.woohahaapps.study.diary.utils;

import com.nimbusds.jwt.JWTClaimsSet;
import com.nimbusds.jwt.SignedJWT;

import java.text.ParseException;
import java.util.Map;

public class AppleIdTokenParser {
    /**
     * Apple의 id_token을 파싱하고 클레임을 추출합니다.
     *
     * @param idToken Apple 서버에서 반환된 id_token
     * @return 사용자 정보가 포함된 클레임
     */
    public static Map<String, Object> parseIdToken(String idToken) {
        try {
            // id_token 파싱
            SignedJWT signedJWT = SignedJWT.parse(idToken);

            // JWT의 Claims 추출
            JWTClaimsSet claims = signedJWT.getJWTClaimsSet();

            // Claims를 Map 형태로 반환
            return claims.getClaims();
        } catch (ParseException e) {
            throw new IllegalArgumentException("Failed to parse id_token", e);
        }
    }
}

이제 프로젝트를 빌드하여 실행해보면 Apple 계정 로그인이 성공하는 것을 확인할 수가 있다.

다만, Apple 은 name 정보값은 전달해주지 않고, email 도 사용자 선택에 따라 hidden 이메일 주소를 전달해주는 경우가 있으므로 hidden 이메일 주소를 전달받은 경우에는 사용자로부터 정상 이메일 주소를 받도록 처리해주는 것이 좋겠다.

...
    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
...
            if (claims.containsKey("id_token")) {
                String idToken = (String) claims.get("id_token");
                Map<String, Object> extractedUserInfo = new HashMap<>(extractUserInfoFromIdToken(idToken));
                System.out.println("Extracted User Info: " + extractedUserInfo);
                if ((Boolean) extractedUserInfo.get("is_private_email"))
                    extractedUserInfo.remove("email");
                oAuth2UserInfo = new AppleOAuth2UserInfo(extractedUserInfo);
...

만약 Apple 로 로그인 기록을 삭제하고자 한다면, 애플 계정 사이트(https://account.apple.com/)로 로그인해서 "로그인 및 보안" 메뉴 화면으로 이동 후 "Apple로 로그인" 목록에서 해당 사이트를 삭제하면 된다.

 

반응형