본문 바로가기

개발/Spring

#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: 'bcpkix-jdk15on', version: '1.70'

 

 

 


 

  •  application.yml
    • application-oauth.yml를 include 한다.
    • (application.yml에 spring security oauth2 client를 설정했다면 무시)
spring:
  profiles:
    include: oauth
    .
    .
    .

 


 

  • application-oauth.yml
    • . p8파일은 키파일이며 한번 다운로드하면 다시 다운로드할 수 없으니 유의해야 함. 파일이름에 확장자인. p8 포함해서 기재
spring:
  config:
    activate:
      on-profile: {profile ex) local, develop...} 
  security:
    oauth2:
      client:
        registration:
          apple:
            clientId: {clientId}
            clientSecret: {.p8 파일 이름}/{KeyId}/{TeamId}
            redirect-uri: {domain}/login/oauth2/code/apple
            authorization-grant-type: authorization_code
            client-authentication-method: POST
            client-name: Apple
            scope:
              - email
        provider:
          apple:
            authorizationUri: https://appleid.apple.com/auth/authorize?response_mode=form_post
            tokenUri: https://appleid.apple.com/auth/token

 


 

  • CustomRequestEntityConverter.java
    • 애플의 token uri를 호출할 파라미터를 Custom하는 소스
public class CustomRequestEntityConverter implements Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> {

    private OAuth2AuthorizationCodeGrantRequestEntityConverter defaultConverter;

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

    @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();
        //Apple일 경우 secret key를 동적으로 세팅
        if(registrationId.contains("apple")){
            params.set("client_secret", createAppleClientSecret(params.get("client_id").get(0), params.get("client_secret").get(0)));
        }
        return new RequestEntity<>(params, entity.getHeaders(),
                entity.getMethod(), entity.getUrl());
    }

	//Apple Secret Key를 만드는 메서드
    public String createAppleClientSecret(String clientId, String secretKeyResource) {
        String clientSecret = "";
        //application-oauth.yml에 설정해놓은 apple secret Key를 /를 기준으로 split
        String[] secretKeyResourceArr = secretKeyResource.split("/");
        try {
            InputStream inputStream = new ClassPathResource("{.p8 파일 경로}" + secretKeyResourceArr[0]).getInputStream();
            File file = File.createTempFile("appleKeyFile",".p8");
            try {
                FileUtils.copyInputStreamToFile(inputStream, file);
            } finally {
                IOUtils.closeQuietly(inputStream);
            }

            String appleKeyId = secretKeyResourceArr[1];
            String appleTeamId = secretKeyResourceArr[2];
            String appleClientId = clientId;

            PEMParser pemParser = new PEMParser(new FileReader(file));
            JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
            PrivateKeyInfo privateKeyInfo = (PrivateKeyInfo) pemParser.readObject();

            PrivateKey privateKey = converter.getPrivateKey(privateKeyInfo);

            clientSecret = Jwts.builder()
                    .setHeaderParam(JwsHeader.KEY_ID, appleKeyId)
                    .setIssuer(appleTeamId)
                    .setAudience("https://appleid.apple.com")
                    .setSubject(appleClientId)
                    .setExpiration(new Date(System.currentTimeMillis() + (1000 * 60 * 5)))
                    .setIssuedAt(new Date(System.currentTimeMillis()))
                    .signWith(privateKey, SignatureAlgorithm.ES256)
                    .compact();

        } catch (IOException e) {
            log.error("Error_createAppleClientSecret : {}-{}", e.getMessage(), e.getCause());
        }
        log.info("createAppleClientSecret : {}", clientSecret);
        return clientSecret;
    }
}

여기서 convert된 param은 라이브러리의 DefaultAuthorizationCodeTokenResponseClient 클래스의 getTokenResponse 메서드로 리턴되고 getResponse를 통해 애플로부터 AccessToken과 id_token을 가져온다.

[spring security oauth2 client] DefaultAuthorizationCodeTokenResponseClient.java
client_secret값이 동적으로 세팅된 모습
getResponse(request) 성공 결과

 


 

  • UserOAuth2Service.java
    • 다른 provider 회원은 회원 정보를 provider로 부터 받아와야 하므로 delegate.loadUser(userRequest)를 호출하도록 하고 애플은 id_token을 decode해서 회원 정보를 가져오게 한다. 만약 애플 로그인만 연동했다면 분기 처리 안해도 된다.
@RequiredArgsConstructor
@Service
public class UserOAuth2Service extends DefaultOAuth2UserService {

    @Override
    public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException {
        String registrationId = userRequest.getClientRegistration().getRegistrationId();

        //access token을 이용해 provider 서버로부터 사용자 정보를 받아온다.
        OAuth2UserService<OAuth2UserRequest, OAuth2User> delegate = new DefaultOAuth2UserService();

        //Apple의 경우 id_token에 회원정보가 있으므로 회원정보 API 호출과정 생략
        Map<String, Object> attributes;
        if(registrationId.contains("apple")){
            String idToken = userRequest.getAdditionalParameters().get("id_token").toString();
            attributes = decodeJwtTokenPayload(idToken);
            attributes.put("id_token", idToken);
        }else{
            OAuth2User oAuth2User = delegate.loadUser(userRequest);
            attributes = oAuth2User.getAttributes();
        }

        //provider에서 넘어온 회원 정보에서 필요한 파라미터만 추출해 DTO로 변환
        OAuthAttributes customAttributes = OAuthAttributes.of(registrationId, attributes);

        return new DefaultOAuth2User(Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")),
                                                                customAttributes.getAttributes(),
                                                                customAttributes.getNameAttributeKey());
    }
    
    //JWT Payload부분 decode 메서드
    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;
    }
}

 


 

  • SecurityConfiguration.java
    • custom class들을 security config에 추가한다. 
    • successHandler는 추가해도되고 안해도 된다. 저 부분의 소스는 공개하지 않았다.
private final UserOAuth2Service userOAuth2Service;
private final OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler;
    
@Bean
protected SecurityFilterChain configure(HttpSecurity httpSecurity) throws Exception {
    httpSecurity
                .
                .
                .
                
                .oauth2Login()
                .tokenEndpoint().accessTokenResponseClient(accessTokenResponseClient())
                .and()
                .userInfoEndpoint().userService(userOAuth2Service)
                .and()
                .successHandler(oAuth2AuthenticationSuccessHandler);
        return httpSecurity.build();
    }

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

        return accessTokenResponseClient;
    }
}

 

 

반응형