team-forAdhd / forAdhd-server

For ADHD 서버
1 stars 2 forks source link

애플 로그인 구현 #36

Closed jkde7721 closed 3 months ago

jkde7721 commented 3 months ago

💻 구현 내용

애플 로그인 구현

spring-oauth2-flow

⁉️ 네이버, 카카오, 구글의 경우 client-secret 값이 고정되어 있기 때문에 application-oauth2.yml에 설정된 값을 읽어와 적절한 RequestEntity 객체를 스프링이 알아서 생성해주어 별도 설정 필요 없음(기본적으로 ClientAuthenticationMethodValidatingRequestEntityConverter 객체 의존), but 애플의 경우 team-id, key-id, client-id 등의 값을 가지고 JWT 형식의 client-secret 을 직접 생성 후 요청해야 함 → 커스텀 Converter 정의(SecurityConfig 내에서 accessTokenResponseClient 빈 등록한 부분 참고)

//Authorization Code 발급 응답 → Access Token 발급을 위한 요청 객체 생성하는 converter
@Slf4j
@Component
public class CustomOAuth2AuthorizationCodeGrantRequestEntityConverter
        implements Converter<OAuth2AuthorizationCodeGrantRequest, RequestEntity<?>> {

    private static final int CLIENT_SECRET_EXPIRY_MONTH = 3;

    @Value("${apple.url}")
    private String APPLE_URL;

    @Value("${apple.key-path}")
    private String APPLE_KEY_PATH;

    @Value("${apple.client-id}")
    private String APPLE_CLIENT_ID;

    @Value("${apple.team-id}")
    private String APPLE_TEAM_ID;

    @Value("${apple.key-id}")
    private String APPLE_KEY_ID;

    private OAuth2AuthorizationCodeGrantRequestEntityConverter defaultConverter;

    public CustomOAuth2AuthorizationCodeGrantRequestEntityConverter() {
        this.defaultConverter = new OAuth2AuthorizationCodeGrantRequestEntityConverter();
    }

    @Override
    public RequestEntity<?> convert(OAuth2AuthorizationCodeGrantRequest request) {
        RequestEntity<?> requestEntity = defaultConverter.convert(request); //스프링이 기본적으로 제공해주는 converter
        String registrationId = request.getClientRegistration().getRegistrationId();
        Optional<Provider> optionalProvider = Provider.from(registrationId);
        MultiValueMap<String, String> body = (MultiValueMap<String, String>) requestEntity.getBody();
        if (optionalProvider.isPresent() && optionalProvider.get() == Provider.APPLE) {
            body.set(OAuth2ParameterNames.CLIENT_SECRET, createClientSecret()); //애플인 경우에만 client-secret 값 재설정
        }
        return new RequestEntity<>(body, requestEntity.getHeaders(), requestEntity.getMethod(), requestEntity.getUrl());
    }

    //참고: https://developer.apple.com/documentation/accountorganizationaldatasharing/creating-a-client-secret
    //애플이 하라는 대로 client-sercret 생성
    public String createClientSecret() {
        Instant now = Instant.now();
        Date issuedAt = Date.from(now);
        Date expiration = Date.from(now.atZone(ZoneOffset.UTC).plusMonths(CLIENT_SECRET_EXPIRY_MONTH).toInstant());
        Map<String, Object> header = new HashMap<>();
        header.put(DefaultJwsHeader.ALGORITHM, JwsAlgorithms.ES256);
        header.put(DefaultJwsHeader.KEY_ID, APPLE_KEY_ID);

        return Jwts.builder()
                .setHeaderParams(header)
                .setIssuer(APPLE_TEAM_ID)
                .setIssuedAt(issuedAt)
                .setExpiration(expiration)
                .setAudience(APPLE_URL)
                .setSubject(APPLE_CLIENT_ID)
                .signWith(getPrivateKey())
                .compact();
    }

    //PEM 파일을 읽어 Private Key 생성
    public PrivateKey getPrivateKey() {
        ClassPathResource resource = new ClassPathResource(APPLE_KEY_PATH);
        try (InputStream inputStream = resource.getInputStream();
            PEMParser pemParser = new PEMParser(new StringReader(IOUtils.toString(inputStream, StandardCharsets.UTF_8)))) {
            PrivateKeyInfo privateKeyInfo = PrivateKeyInfo.getInstance(pemParser.readObject());
            JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
            return converter.getPrivateKey(privateKeyInfo);
        } catch (IOException e) {
            log.error("Apple Key Parsing Error", e);
            throw new RuntimeException(e);
        }
    }
}

⁉️ JWT 형식의 Id Token을 디코딩하여 payload의 유저 정보를 가져와야함 + 애플은 이 Id Token의 유효성을 검증할 것을 권장하는데 이 JWT 토큰을 검증하기 위해서는 키가 필요함 → 이 키를 조회해 오는 API가 또 따로 있음

디코딩된 Id Token Payload 예시

{
  "iss": "https://appleid.apple.com",
  "aud": "${APPLE_CLIENT_ID}",
  "exp": 1718729646,
  "iat": 1718643246,
  "sub": "123456.a1d12c12ec12345c1bf1234cdb1234bc.1234",
  "at_hash": "asdf-asdfer_m1Pjkluio",
  "email": "dana.kim1999@icloud.com",
  "email_verified": true,
  "auth_time": 1718643245,
  "nonce_supported": true
}
@Transactional(readOnly = true)
@RequiredArgsConstructor
@Service
public class OAuth2UserServiceImpl extends DefaultOAuth2UserService {

    private final UserService userService;
    private final OAuth2UserInfoService oAuth2UserInfoService;
    private final AuthSocialLoginRepository authSocialLoginRepository;

    @Transactional
    @Override
    public OAuth2User loadUser(OAuth2UserRequest oAuth2UserRequest) throws OAuth2AuthenticationException {
        String registrationId = oAuth2UserRequest.getClientRegistration().getRegistrationId();
        String nameAttributeKey = oAuth2UserRequest.getClientRegistration().getProviderDetails()
            .getUserInfoEndpoint().getUserNameAttributeName();
        Map<String, Object> attributes = oAuth2UserInfoService.getAttributes(oAuth2UserRequest, super::loadUser); //유저 정보를 Map 타입으로 조회(내부적으로 소셜 로그인 타입에 따라 분기 처리)

        OAuth2Attributes oAuth2Attributes = OAuth2AttributesFactory.valueOf(registrationId, nameAttributeKey, attributes);
        User user = getSocialLoginedUser(oAuth2Attributes);
        return new OAuth2UserImpl(attributes, nameAttributeKey, user);
    }
}

네이버, 카카오, 구글, 애플 별로 유저 정보 조회해오는 방식이 다 다름

@RequiredArgsConstructor
@Service
public class OAuth2UserInfoService {

    private final NaverOAuth2UserAttributesServiceImpl naverOAuth2UserAttributesService;
    private final KakaoOAuth2UserAttributesServiceImpl kakaoOAuth2UserAttributesService;
    private final GoogleOAuth2UserAttributesServiceImpl googleOAuth2UserAttributesService;
    private final AppleOAuth2UserAttributesServiceImpl appleOAuth2UserAttributesService;

    public final Map<String, Object> getAttributes(OAuth2UserRequest oAuth2UserRequest,
                                                Function<OAuth2UserRequest, OAuth2User> defaultOAuth2UserFunction) {
        String registrationId = oAuth2UserRequest.getClientRegistration().getRegistrationId();
        Provider provider = Provider.from(registrationId)
                .orElseThrow(() -> new BusinessException(NOT_SUPPORTED_SNS_TYPE));
        Map<String, Object> attributes = getDefaultAttributes(provider, defaultOAuth2UserFunction, oAuth2UserRequest);
        switch (provider) {
            case NAVER -> {
                return naverOAuth2UserAttributesService.getAttributes(oAuth2UserRequest, attributes);
            }
            case KAKAO -> {
                return kakaoOAuth2UserAttributesService.getAttributes(oAuth2UserRequest, attributes);
            }
            case GOOGLE -> {
                return googleOAuth2UserAttributesService.getAttributes(oAuth2UserRequest, attributes);
            }
            case APPLE -> {
                return appleOAuth2UserAttributesService.getAttributes(oAuth2UserRequest, attributes);
            }
            default -> throw new BusinessException(NOT_SUPPORTED_SNS_TYPE);
        }
    }

    private Map<String, Object> getDefaultAttributes(Provider provider,
                                                    Function<OAuth2UserRequest, OAuth2User> defaultOAuth2UserFunction,
                                                    OAuth2UserRequest oAuth2UserRequest) {
        if (provider == Provider.APPLE) return new HashMap<>(); //애플은 그런 것 없으므로 빈 HashMap 객체 반환
        return defaultOAuth2UserFunction.apply(oAuth2UserRequest).getAttributes(); //파라미터로 전달된 Function은 DefaultOAuth2UserService(스프링이 기본적으로 제공하는 유저 정보 조회 서비스)의 loadUser 메소드로 각각 적절한 유저 정보 조회 API 호출
    }
}
@Slf4j
@RequiredArgsConstructor
@Service
public class AppleOAuth2UserAttributesServiceImpl implements OAuth2UserAttributesService {

    private final AppleIdTokenValidator appleIdTokenValidator;
    private final JwtService jwtService;

    @Override
    public Map<String, Object> getAttributes(OAuth2UserRequest oAuth2UserRequest, Map<String, Object> attributes) {
        String idToken = oAuth2UserRequest.getAdditionalParameters().get(ID_TOKEN).toString();
        appleIdTokenValidator.validate(idToken); //id 토큰 검증

        Map<String, Object> decodedPayload = jwtService.decodePayload(idToken);
        attributes.putAll(decodedPayload); //유저 정보 조회
        return attributes;
    }
}
@RequiredArgsConstructor
@Component
public class AppleIdTokenValidator {

    public static final String NONCE_SUPPORTED_CLAIM_KEY = "nonce_supported";
    private final ApplePublicKeyClient applePublicKeyClient;
    private final ApplePublicKeyGenerator applePublicKeyGenerator;
    private final JwtService jwtService;

    @Value("${apple.url}")
    private String APPLE_URL;

    @Value("${apple.client-id}")
    private String APPLE_CLIENT_ID;

    //참고: https://developer.apple.com/documentation/sign_in_with_apple/sign_in_with_apple_rest_api/verifying_a_user/
    //위글에서 애플이 하라는대로 토큰 검증
    public void validate(String idToken) {
        Map<String, Object> decodedHeader = jwtService.decodeHeader(idToken);
        DefaultJwsHeader jwtHeader = new DefaultJwsHeader(decodedHeader);
        PublicKey publicKey = getApplePublicKey(decodedHeader);

        //Verify the JWS E256 signature using the server’s public key
        //Verify that the time is earlier than the exp value of the token
        jwtService.validateTokenExpiry(idToken, publicKey);
        Claims claims = jwtService.parseToken(idToken, publicKey);

        //Verify the nonce for the authentication → "nonce_supported": true
        Boolean nonceSupported = claims.get(NONCE_SUPPORTED_CLAIM_KEY, Boolean.class);
        if (!nonceSupported) {
            throw new IncorrectClaimException(jwtHeader, claims,
                    "Invalid Apple Id Token: nonce_supported = " + nonceSupported);
        }

        //Verify that the iss field contains https://appleid.apple.com → "iss": "https://appleid.apple.com"
        String issuer = claims.getIssuer();
        if (!issuer.equals(APPLE_URL)) {
            throw new IncorrectClaimException(jwtHeader, claims, "Invalid Apple Id Token: issuer = " + issuer);
        }

        //Verify that the aud field is the developer’s client_id → "aud": ${APPLE_CLIENT_ID}
        String audience = claims.getAudience();
        if (!audience.equals(APPLE_CLIENT_ID)) {
            throw new IncorrectClaimException(jwtHeader, claims, "Invalid Apple Id Token: audience = " + audience);
        }
    }

    //참고: https://developer.apple.com/documentation/sign_in_with_apple/fetch_apple_s_public_key_for_verifying_token_signature
    private PublicKey getApplePublicKey(Map<String, Object> headers) {
        ApplePublicKeyListResponse applePublicKeyListResponse = applePublicKeyClient.getApplePublicKeyList(); //apple public key 조회하는 API 호출
        return applePublicKeyGenerator.generatePublicKey(headers, applePublicKeyListResponse); //public key 응답값으로 토큰 검증에 필요한 PublicKey 객체 생성
    }
}

🛠️ 개발 오류 사항

@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class AppleOAuth2Attributes extends OAuth2Attributes {

    private static final String ID_KEY = "sub";
    private static final String EMAIL_VERIFIED_KEY = "email_verified";
    private static final String EMAIL_KEY = "email";

    public static OAuth2Attributes of(String nameAttributesKey, Map<String, Object> attributes) {
        return GoogleOAuth2Attributes.builder()
                .id(parseId(attributes))
                .name("") //빈문자열
                .email(parseEmail(attributes))
                .isVerifiedEmail(parseIsVerifiedEmail(attributes))
                .gender(Gender.UNKNOWN) //알 수 없음 기본값
                .ageRange("") //빈문자열
                .birth(LocalDate.now()) //현재 날짜 기본값
                .provider(Provider.APPLE)
                .build(); //이름, 성별, 나이대, 생년월일 정보를 애플로부터 가져올 수 없기 때문에 위와 같이 기본값 설정
    }

    private static String parseId(Map<String, Object> attributes) {
        return String.valueOf(attributes.get(ID_KEY));
    }

    private static String parseEmail(Map<String, Object> attributes) {
        return String.valueOf(attributes.get(EMAIL_KEY));
    }

    private static Boolean parseIsVerifiedEmail(Map<String, Object> attributes) {
        return (boolean) attributes.get(EMAIL_VERIFIED_KEY);
    }
}

🗣️ For 리뷰어

애플 로그인 테스트 방법

참고

close #35

jkde7721 commented 3 months ago

네 확인했습니다!!

  • 세진님께 계정 전달 받아서 테스트 한 후 연락 드리겠습니다.
  • 이름 저장하는 타이밍이 애매하면 애플 회원가입 후 이름 입력받는 창을 넣는건 어떨까요?? (번거로울 것 같기도 합니다..)
  • 여쭤보려고 하긴 했는데 약 리뷰탭 response dto에서 user_privacy 테이블에 있는 agerange랑 gender를 가져와서 내려주긴 해야 하거든요 / userservice쪽에는 userprivacy쪽 데이터를 가져오는 메소드가 없어서요..! 혹시 수정해주실 수 있나요? 아니면 직접 엔티티에서 getter 사용해서 가져오면 되는걸까요??

아 일단 포에이 서비스에 필요한 user_profile의 nickname 정보는 소셜 회원가입 후 직접 입력받긴 하는데(화면이 없네요... 일단 이거는 제가 디자인에 요청드릴께요), name(실명? 근데 일반 회원가입 시에도 정말 실제 이름인지 외부 api 호출하여 검증하는 로직은 없음) 정보가 정말 필요하다면 동일하게 진행하면 될 것 같습니다. (일단 제가 구현한 쪽은 user_privacy의 name 정보 사용하지 않습니다.)

그리고 아시겠지만 user_privacy의 age_range 타입은 문자열로 소셜 로그인 시 응답으로 내려온 값 그대로 가져다 저장해서 제멋대로? 생기기도 했고(없애도 될 것 같아요😂), 시간이 지남에 따라 10대 -> 20대가 될 수 있잖아요. 그래서 제 생각엔 birth 정보 가져다가 자바단에서 나이대 직접 계산하는 로직 필요할 것 같습니다. (UserPrivacy 엔티티 내부에 생성)

성별이랑 나이대 정보는 리뷰 작성자 ID로 UserPrivacy 엔티티 조회해서 getGender, calculateAgeRange 메소드 호출하면 되지 않을까 해요