mingg IT

[Spring Security Oauth2.0 Client] Apple 로그인 본문

BackEnd

[Spring Security Oauth2.0 Client] Apple 로그인

mingg123 2023. 8. 21. 15:32

드디어 올 것이 왔다. 미루고 미루다가 미룰 순 없어서 애플 로그인을 구현했다. 

Rest-API 방식을 이용한 예시는 많은데, Spring Security Oauth2.0 Client를 이용한 레퍼런스가 다른 SNS 로그인에 비해 매우 적었다.

개발하는 기간동안 나름 고생을해서 이번에 정리해두려고 한다.

 

 

application.yml에 apple 정보 추가 

  security:
    oauth2.client:
      registration:
        apple:
          clientId: {애플로부터 받은clientId}
          clientSecret: {애플로부터 받은 key 파일}.p8
          redirectUri: "${backend-rooturl-origin}/login/oauth2/code/apple"
          authorizationGrantType: authorization_code
          clientAuthenticationMethod: POST
          clientName: Apple
          scope:
            - name
            - email
        naver:
          clientId: ${naver.client.key}
          clientSecret: ${naver.secret.key}
          clientAuthenticationMethod: post
          authorizationGrantType: authorization_code
          redirectUri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
          scope:
            - nickname
            - email
            - profile_image
          clientName: naver
        kakao:
          clientId: ${kakao.client.key}
          clientSecret: ${kakao.secret.key}
          clientAuthenticationMethod: post
          authorizationGrantType: authorization_code
          redirectUri: "{baseUrl}/{action}/oauth2/code/{registrationId}"
          scope:
            - profile_nickname
            - profile_image
            - account_email
          clientName: kakao
      provider:
        apple:
          authorizationUri: https://appleid.apple.com/auth/authorize?scope=name%20email&response_mode=form_post
          tokenUri: https://appleid.apple.com/auth/token
        naver:
          authorizationUri: https://nid.naver.com/oauth2.0/authorize
          tokenUri: https://nid.naver.com/oauth2.0/token
          userInfoUri: https://openapi.naver.com/v1/nid/me
          userNameAttribute: response
        kakao:
          authorizationUri: https://kauth.kakao.com/oauth/authorize
          tokenUri: https://kauth.kakao.com/oauth/token
          userInfoUri: https://kapi.kakao.com/v2/user/me
          userNameAttribute: id

 

 

CustomRequestEntityConvert.java 작성 

apple 같은 경우에는 네이버, 카카오와는 다르게 client_secret을 key파일과, teamId, clientId등을 이용하여 만들어야한다. 

@Slf4j
public class CustomRequestEntityConverter implements Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> {

  private OAuth2AuthorizationCodeGrantRequestEntityConverter defaultConverter;

  public CustomRequestEntityConverter() {
    defaultConverter = new OAuth2AuthorizationCodeGrantRequestEntityConverter();
  }

  private final String APPLE_URL = "https://appleid.apple.com";
  private final String APPLE_KEY_PATH = "static/apple/{apple로 부터 받은 key파일명}.p8";
  private final String APPLE_CLIENT_ID = "{apple로 부터 받은 clientId";
  private final String APPLE_TEAM_ID = "{apple로 부터 받은 teamId}";
  private final String APPLE_KEY_ID = "{apple로 부터 받은 keyId}";


  @Override
  public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest req) {
    RequestEntity<?> entity = defaultConverter.convert(req);
    String registrationId = req.getClientRegistration().getRegistrationId();
    MultiValueMap<String, String> params = (MultiValueMap<String, String>) entity.getBody();
    if (registrationId.contains("apple")) {
      try {
        params.set("client_secret", createClientSecret());
      } catch (IOException e) {
        throw new RuntimeException(e);
      }
    }
    return new RequestEntity<>(params, entity.getHeaders(),
      entity.getMethod(), entity.getUrl());
  }

  public PrivateKey getPrivateKey() throws IOException {
    ClassPathResource resource = new ClassPathResource(APPLE_KEY_PATH);
    // 배포시 jar 파일을 찾지 못함
    //String privateKey = new String(Files.readAllBytes(Paths.get(resource.getURI())));

    InputStream in = resource.getInputStream();
    PEMParser pemParser = new PEMParser(new StringReader(IOUtils.toString(in, StandardCharsets.UTF_8)));
    PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject();
    JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
    return converter.getPrivateKey(object);
  }


  public String createClientSecret() throws IOException {
    Date expirationDate = Date.from(LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant());
    Map<String, Object> jwtHeader = new HashMap<>();
    jwtHeader.put("kid", APPLE_KEY_ID);
    jwtHeader.put("alg", "ES256");

    return Jwts.builder()
      .setHeaderParams(jwtHeader)
      .setIssuer(APPLE_TEAM_ID)
      .setIssuedAt(new Date(System.currentTimeMillis())) // 발행 시간 - UNIX 시간
      .setExpiration(expirationDate) // 만료 시간
      .setAudience(APPLE_URL)
      .setSubject(APPLE_CLIENT_ID)
      .signWith(getPrivateKey())
      .compact();
  }
}

client_secret이 ey~ 토큰형식으로 변형되어야 한다. 

 

SecurityConfig 내 Bean 등록 및 수정 

SecurityConfig.java

애플의 clientSecret을 만들기위해 만들어주었던 CustomRequestEntityConvert를 SecurityConfig 내 Bean으로 등록해준다.

 @Bean
  public OAuth2AccessTokenResponseClient<OAuth2AuthorizationCodeGrantRequest> accessTokenResponseClient() {
    DefaultAuthorizationCodeTokenResponseClient accessTokenResponseClient = new DefaultAuthorizationCodeTokenResponseClient();
    accessTokenResponseClient.setRequestEntityConverter(new CustomRequestEntityConverter());

    return accessTokenResponseClient;
  }

 

.tokenEndpoint().accessTokenResponseClient(accessTokenResponseClient()) 추가해준다. 

  private HttpSecurity setOauth2ToHttpSecurity(HttpSecurity http) throws Exception {

    http.oauth2Login()
      .tokenEndpoint().accessTokenResponseClient(accessTokenResponseClient()).and()
      .authorizationEndpoint()
      .baseUri("/oauth2/authorization")
      .authorizationRequestRepository(oAuth2AuthorizationRequestBasedOnCookieRepository())
      .and()
      .redirectionEndpoint()
      .baseUri("/*/oauth2/code/*")
      .and()
      .userInfoEndpoint().userService(oAuth2UserService).and()
      .successHandler(oAuth2AuthenticationSuccessHandler())
      .failureHandler(oAuth2AuthenticationFailureHandler()) 
      .and()
      .addFilterBefore(tokenAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class);
    return http;
  }

아마 해당 플로우는 각자 애플리케이션에서 이용하는 회원가입 및 로그인 플로우에 따라 다를 것이다. 

 

 

 

DefaultAuthorizationCodeTokenResponseClient.java 내 getTokenResponse의 request 내 client_secret이 들어있어야 한다. 

만약 client_secret 값이 비었다면 CustomRequestEntityConvert 잘 동작하는지 확인해봐라. 난 비어있어서 key 파일 유효기간이 다 된건가 한참 찾았는데 파싱을 잘못했었던게 원인 이였다.  

 

 

DefaultAuthorizationCodeTokenResponseClient.java 내 getTokenResponse의 response 결과이다. 

id_token 값이 들어있어야 한다. 

순서가

Oauth2AccessTokenResponse 내 request ->

커스텀한 CustomRequestEntityConvert ->

Oauth2AccessTokenResponse 내 response 라고 생각하면 된다. 

 

 

loadUser 함수 수정 

loadUser 에서 애플과 다른 로그인간에 사용자 정보를 들고오는 방식이 달라서  분기 처리가 필요하다.

public class CustomOAuth2UserService extends DefaultOAuth2UserService {
  private final UserRepository userRepository;

  private static final String APPLE_REGISTRATION_ID = "apple";

  @Override
  public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    String registrationId = userRequest.getClientRegistration().getRegistrationId();
    OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();

    OAuth2User user;
    Map<String, Object> attributes;

    if (registrationId.contains(APPLE_REGISTRATION_ID)) {
      String idToken = userRequest.getAdditionalParameters().get("id_token").toString();
      attributes = decodeJwtTokenPayload(idToken);
      attributes.put("id_token", idToken);
      Map<String, Object> userAttributes = new HashMap<>();
      userAttributes.put("resultcode", "00");
      userAttributes.put("message", "success");
      userAttributes.put("response", attributes);
      
      user = new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), userAttributes, "response");
    } else {
      OAuth2User oAuth2User = delegate.loadUser(userRequest);
      attributes = oAuth2User.getAttributes();
      user = super.loadUser(userRequest);
    }
  }
  
  public Map<String, Object> decodeJwtTokenPayload(String jwtToken) {
    Map<String, Object> jwtClaims = new HashMap<>();
    try {
      String[] parts = jwtToken.split("\\.");
      Base64.Decoder decoder = Base64.getUrlDecoder();

      byte[] decodedBytes = decoder.decode(parts[1].getBytes(StandardCharsets.UTF_8));
      String decodedString = new String(decodedBytes, StandardCharsets.UTF_8);
      ObjectMapper mapper = new ObjectMapper();

      Map<String, Object> map = mapper.readValue(decodedString, Map.class);
      jwtClaims.putAll(map);

    } catch (JsonProcessingException e) {
//        logger.error("decodeJwtToken: {}-{} / jwtToken : {}", e.getMessage(), e.getCause(), jwtToken);
    }
    return jwtClaims;
  }
}

애플의 경우 네이버나 카카오와 방식이 다르기때문에 DefaultOauth2User를 커스텀하게 만들어주었다. 

 

userRequest 내부이다. 

 

 

id_token 값을 decode 하게되면 sub, email 내가 필요한 정보를 얻을 수 있다. (드디어 ..!)

user이름은 없더라.. 공식문서에도 id_token 내에서는 제공하지 않는다고 한다. 

 

 

 

나를 고통스럽게 했던 문제와 해결 방안에 대해 작성해보도록 하겠다.. 

localhost, http 에서 테스트 불가능 

네이버나, 카카오 로그인을 테스트할 경우에는 localhost 인 로컬로 테스트가 가능했는데

애플은 보안때문인지 무슨생각인지 이걸 막아뒀다. 

 

나는 스테이징 우리 서버에 배포하고, Remote Dubug 붙여서 테스트 했다... ㅂㄷㅂㄷ

저기서 도메인은 물론 return url 에 localhost는 사용이 불가능 하다. 

 

 

Client_Secret 정보가 비어있는 경우 

이 문제는 처음에 apple develop 계정으로 만든 key파일인 p.8 의 기한인 6개월이 지나서 안되는건가 해맸다.

원인은 그냥 client_secret 파일을 파싱하는 로직이 잘못되어서 였다.

 

 private final String APPLE_KEY_PATH = "static/apple/파일명.p8";

 public String createClientSecret() throws IOException {
    Date expirationDate = Date.from(LocalDateTime.now().plusDays(30).atZone(ZoneId.systemDefault()).toInstant());
    Map<String, Object> jwtHeader = new HashMap<>();
    jwtHeader.put("kid", APPLE_KEY_ID);
    jwtHeader.put("alg", "ES256");

    return Jwts.builder()
      .setHeaderParams(jwtHeader)
      .setIssuer(APPLE_TEAM_ID)
      .setIssuedAt(new Date(System.currentTimeMillis())) // 발행 시간 - UNIX 시간
      .setExpiration(expirationDate) // 만료 시간
      .setAudience(APPLE_URL)
      .setSubject(APPLE_CLIENT_ID)
      .signWith(getPrivateKey())
      .compact();
  }
  
  
public PrivateKey getPrivateKey() throws IOException {
    ClassPathResource resource = new ClassPathResource(APPLE_KEY_PATH);
    // 배포시 jar 파일을 찾지 못함
    //String privateKey = new String(Files.readAllBytes(Paths.get(resource.getURI())));

    InputStream in = resource.getInputStream();
    PEMParser pemParser = new PEMParser(new StringReader(IOUtils.toString(in, StandardCharsets.UTF_8)));
    PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject();
    JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
    return converter.getPrivateKey(object);
}

 

 

 

애플 로그인시 이메일 정보가 오지 않는 경우 

scope에 name, email을 넣어주었음에도 오지 않았다. 다른 블로그 글에도 최초의 로그인시에만 온다는데 그러면 테스트 할때 어캐 하느냐?

 

아래에 접속하여 로그인한다. 

https://appleid.apple.com/account/manage

 

Apple ID

Your Apple ID is the account you use for all Apple services

appleid.apple.com

접속해서 Apple로 로그인 사용 중단을 클릭한다. 

 

 

 

 

id_token 값에 user 이름 정보가 없는 경우 

https://appleid.apple.com/auth/authorize?scope=name%20email&response_mode=form_post

이렇게 보내면 user정보가 와야하는게 아닌가? 

 

안온다... 

 

공식문서보면 Id_token 값에 user정보가 없다고하는데 받아오는 방법을 아는 분은 제발 알려주면 좋겠다. 

 

여기에도 안온다는데 .. 쩝 

https://developer.apple.com/forums/thread/690339

 

Sign-in with Apple: user's name wo… | Apple Developer Forums

As per the Developper documentation (https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_js/incorporating_sign_in_with_apple_into_other_platforms, paragraph Send the Required Query Parameters), I'm trying to get the user's name

developer.apple.com

 

loadUser 에서 터지는 경우 

  user = super.loadUser(userRequest);

네이버나 카카오 로그인을 할 경우에는 이런식으로 사용하면 바로 User 정보를 읽어왔다. (nickName 같은 것들)

 

허나 우리의 애플 같은 경우에는 방식이 다르다. (id_token 값을 decode 해서 읽어온다) 

 

loadUser 내부 코드를 살펴보면

	@Override
	public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
		Assert.notNull(userRequest, "userRequest cannot be null");
		if (!StringUtils
				.hasText(userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint().getUri())) {
			OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_INFO_URI_ERROR_CODE,
					"Missing required UserInfo Uri in UserInfoEndpoint for Client Registration: "
							+ userRequest.getClientRegistration().getRegistrationId(),
					null);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		String userNameAttributeName = userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()
				.getUserNameAttributeName();
		if (!StringUtils.hasText(userNameAttributeName)) {
			OAuth2Error oauth2Error = new OAuth2Error(MISSING_USER_NAME_ATTRIBUTE_ERROR_CODE,
					"Missing required \"user name\" attribute name in UserInfoEndpoint for Client Registration: "
							+ userRequest.getClientRegistration().getRegistrationId(),
					null);
			throw new OAuth2AuthenticationException(oauth2Error, oauth2Error.toString());
		}
		RequestEntity<?> request = this.requestEntityConverter.convert(userRequest);
		ResponseEntity<Map<String, Object>> response = getResponse(userRequest, request);
		Map<String, Object> userAttributes = response.getBody();
		Set<GrantedAuthority> authorities = new LinkedHashSet<>();
		authorities.add(new OAuth2UserAuthority(userAttributes));
		OAuth2AccessToken token = userRequest.getAccessToken();
		for (String authority : token.getScopes()) {
			authorities.add(new SimpleGrantedAuthority("SCOPE_" + authority));
		}
		return new DefaultOAuth2User(authorities, userAttributes, userNameAttributeName);
	}
userRequest.getClientRegistration().getProviderDetails().getUserInfoEndpoint()

이 부분에서 터질 것이다. 내가 정의한 application.yml 에도 정보가 없고.. 

그래서 loadUser에는 분기 처리를 해야했다.

 

 

@Override
  public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
    String registrationId = userRequest.getClientRegistration().getRegistrationId();
    OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();

    OAuth2User user;
    Map<String, Object> attributes;

    if (registrationId.contains(APPLE_REGISTRATION_ID)) {
      String idToken = userRequest.getAdditionalParameters().get("id_token").toString();
      attributes = decodeJwtTokenPayload(idToken);
      attributes.put("id_token", idToken);
      Map<String, Object> userAttributes = new HashMap<>();
      userAttributes.put("resultcode", "00");
      userAttributes.put("message", "success");
      userAttributes.put("response", attributes);

      user = new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), userAttributes, "response");
    } else {
      OAuth2User oAuth2User = delegate.loadUser(userRequest);
      attributes = oAuth2User.getAttributes();
      user = super.loadUser(userRequest);
    }
}

DefaultOAuth2User 를 만들어주었고, 형식을 맞추기위해서 userAttributes 를 만들어주었다. 

 

 

요약

 

  • 네이버랑 카카오 로그인에 비해 굉장히 오래걸렸고, 시큐리티 Oauth2 client를 이용하는 레퍼런스를 찾기가 힘들었다. (application.yml 에도 어떤식으로 작성해줘야할지도 모르겠더라.)
  • 처음엔 포기하고 Rest-API 형식으로 구현했다가, 이후 시큐리티 방식으로 다시 수정하였다.
  • 그래도 구현하면서 시큐리티 내부 DefaultAuthorizationCodeTokenReponseClient 로직을 직접 디버깅찍어 보면서 공부할 수 있었고, 전체적인 플로우를 이해할 수 있어서 뿌듯했다. 

 

 

아래는 내가 많이 참고한 사이트들이다. (감사합니다)

https://shxrecord.tistory.com/289

 

[Spring Boot]애플 로그인 구현

앱스토어 배포시에 애플 로그인이 필요하다는 말에 개발을 하게 됐었는데 구현이 다른 소셜 로그인에 비해 꽤나 복잡했었습니다. 언젠가 또 개발할 일이 있지 않을까라는 생각에 기록을 남겨봅

shxrecord.tistory.com

 

https://devcheon.tistory.com/98?category=327541

 

#2) Spring Security Oauth2 Client + Apple 로그인 연동하기

build.gradle implementation group: 'org.springframework.security', name: 'spring-security-oauth2-client', version: '5.6.3' implementation group: 'com.nimbusds', name: 'nimbus-jose-jwt', version: '9.30.1' implementation group: 'org.bouncycastle', name: 'bcp

devcheon.tistory.com

 

Comments