[IMAD] 애플 로그인 기능 구현

NCOOKIE·2023년 7월 11일
0

IMAD 프로젝트

목록 보기
7/11
post-thumbnail

들어가며

애플 개발자 계정이 없어서 로그인 기능을 미뤄두고 있다가 iOS 담당하는 친구가 이번에 결제를 하게 되어 구현 및 테스트를 진행하게 되었다.


애플 개발자 설정

애플의 로그인 API를 사용하기 위해서는 여러 사전 설정들이 필요하다. 이 부분에 대한 설명은 다른 분의 블로그에서도 잘 설명해주었으니 링크를 남겨두겠다.

애플 개발자 설정

유의해야 할 점은 Identifiers에서 App IDsServices IDs 둘 다 설정해주어야 한다는 것이다. 처음에 이 부분을 대충 설정하고 넘겼다가 몇 일동안 헤매고 뻘짓을 했었다.

[Services IDs]에 있는 Identifiers에서 redirect url을 설정할 수 있다.


코드 구현

의존성 추가 및 property 값 설정

build.gradle

// Apple login
implementation 'com.nimbusds:nimbus-jose-jwt:3.10'
implementation 'org.bouncycastle:bcpkix-jdk18on:1.72'
implementation 'io.jsonwebtoken:jjwt:0.9.1'

JWT 생성, 암호 디코딩 등을 하기 위한 라이브러리를 추가해준다.

application.yml

apple:
  team-id: "[애플 개발자 팀 아이디]"
  login-key: "[Key ID]"
  client-id: "[Client ID]"
  redirect-url: "[redirect url]"
  key-path: "key/AuthKey.p8"
  • team-id : 인증서(영문) 탭에서 우측 상단에서 이름 뒤에 확인할 수 있음(App ID Prefix)
  • login-key : Keys에서 확인 가능
  • client-id : Identifiers - Services IDs - Identifier
  • redirect url : Services ID에서 설정했던 redirect url
  • key-path : 다운로드 받았던 키 파일은 /src/main/resources/key에 저장했음

DTO

ApiResponse.java

다른 컨트롤러에서도 response를 일관성 있게 보내주기 위해서 만든 DTO 클래스이다. status, data, message로 이루어져 있으며, data 여부는 선택할 수 있다.

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ApiResponse<T> {

    /**
     * status : 정상(success), 예외(error), 오류(fail) 중 한 값을 가짐
     * data : 정상(success)의 경우 실제 전송될 데이터를, 오류(fail)의 경우 유효성 검증에 실패한 데이터의 목록을 응답
     * message : 예외(error)의 경우 예외 메시지를 응답
     */
    private int status;
    private T data;
    private String message;

    // 정상
    public static <T> ApiResponse<T> createSuccess(ResponseCode responseCode, T data) {
        return new ApiResponse<>(responseCode, data);
    }

    public static ApiResponse<?> createSuccessWithNoContent(ResponseCode responseCode) {
        return new ApiResponse<>(responseCode, null);
    }

    // 예외 발생으로 API 호출 실패시 반환
    public static ApiResponse<?> createError(ResponseCode responseCode) {
        return new ApiResponse<>(responseCode, null);
    }

    // 데이터 유효성 문제
    // Hibernate Validator에 의해 유효하지 않은 데이터로 인해 API 호출이 거부될때 반환
    public static ApiResponse<?> createFail(int status, BindingResult bindingResult) {
        Map<String, String> errors = new HashMap<>();

        List<ObjectError> allErrors = bindingResult.getAllErrors();
        for (ObjectError error : allErrors) {
            if (error instanceof FieldError) {
                errors.put(((FieldError) error).getField(), error.getDefaultMessage());
            } else {
                errors.put( error.getObjectName(), error.getDefaultMessage());
            }
        }
        return new ApiResponse<>(status, errors, null);
    }

    private ApiResponse(int status, T data, String message) {
        this.status = status;
        this.data = data;
        this.message = message;
    }

    private ApiResponse(ResponseCode responseCode, T data) {
        this.status = responseCode.getStatus();
        this.data = data;
        this.message = responseCode.getMessage();
    }
}

핵심 기능 구현

AppleController.java

로그인 성공 시 위에서 설정한 redirect url로 받는 authorization code를 service 단에 넘겨준다. 정상적으로 로그인 관련 처리가 끝났다면 관련 데이터를 클라이언트에 보내준다.

@RestController
@RequiredArgsConstructor
public class AppleController {
    private final AppleService appleService;

    @PostMapping("/api/callback/apple")
    public ApiResponse<?> callback(HttpServletRequest request, HttpServletResponse response) {
        // 애플 회원가입 또는 로그인 실패
        if (appleService.login(request.getParameter("code"), response) == null) {
            return ApiResponse.createError(ResponseCode.LOGIN_FAILURE);
        }

        return ApiResponse.createSuccessWithNoContent(ResponseCode.LOGIN_SUCCESS);
    }
}

AppleService.java

서비스에서 사용하는 JWT가 적용된 코드이다. 자세한 내용은 아래에서 설명하겠다.

@Slf4j
@RequiredArgsConstructor
@Service
public class AppleService {

    private final UserAccountRepository userRepository;
    private final JwtService jwtService;

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

    @Value("${apple.login-key}")
    private String APPLE_LOGIN_KEY;

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

    @Value("${apple.redirect-url}")
    private String APPLE_REDIRECT_URL;

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

    private final static String APPLE_AUTH_URL = "https://appleid.apple.com";

    public String getAppleLoginUrl() {
        return APPLE_AUTH_URL + "/auth/authorize"
                + "?client_id=" + APPLE_CLIENT_ID
                + "&redirect_uri=" + APPLE_REDIRECT_URL
                + "&response_type=code%20id_token&scope=name%20email&response_mode=form_post";
    }

    public UserAccount login(String code, HttpServletResponse response) {
        String userId;
        String email;
        String accessToken;

        UserAccount user;

        try {
            JSONParser jsonParser = new JSONParser();
            JSONObject jsonObj = (JSONObject) jsonParser.parse(generateAuthToken(code));

            accessToken = String.valueOf(jsonObj.get("access_token"));

            // ID TOKEN을 통해 회원 고유 식별자 받기
            SignedJWT signedJWT = SignedJWT.parse(String.valueOf(jsonObj.get("id_token")));
            ReadOnlyJWTClaimsSet getPayload = signedJWT.getJWTClaimsSet();

            ObjectMapper objectMapper = new ObjectMapper();
            JSONObject payload = objectMapper.readValue(getPayload.toJSONObject().toJSONString(), JSONObject.class);

            userId = String.valueOf(payload.get("sub"));
            email = String.valueOf(payload.get("email"));

            UserAccount findUser = userRepository
                    .findByAuthProviderAndSocialId(AuthProvider.APPLE, userId)
                    .orElse(null);

            if (findUser == null) {
                // 신규 회원가입의 경우 DB에 저장
                logWithOauthProvider(AuthProvider.APPLE, "신규 회원가입 DB 저장");
                user = userRepository.save(
                        UserAccount.builder()
                                .authProvider(AuthProvider.APPLE)
                                .socialId(userId)
                                .email(email)
                                .role(Role.GUEST)
                                .oauth2AccessToken(accessToken)
                                .build()
                );
            } else {
                // 기존 회원의 경우 access token 업데이트를 위해 DB에 저장
                logWithOauthProvider(AuthProvider.APPLE, "기존 회원 DB 업데이트");
                findUser.setOauth2AccessToken(accessToken);
                user = userRepository.save(findUser);
            }

            loginSuccess(user, response);
            return user;

        } catch (ParseException | JsonProcessingException e) {
            throw new RuntimeException("Failed to parse json data");
        } catch (IOException | java.text.ParseException e) {
            throw new RuntimeException(e);
        }
    }

    public void loginSuccess(UserAccount user, HttpServletResponse response) {
        String accessToken = jwtService.createAccessToken(user.getEmail());
        String refreshToken = jwtService.createRefreshToken();

        jwtService.sendAccessAndRefreshToken(response, accessToken, refreshToken);
        jwtService.updateRefreshToken(user.getEmail(), refreshToken);
    }

    public String generateAuthToken(String code) throws IOException {
        if (code == null) throw new IllegalArgumentException("Failed get authorization code");

        MultiValueMap<String, String> params = new LinkedMultiValueMap<>();
        params.add("grant_type", "authorization_code");
        params.add("client_id", APPLE_CLIENT_ID);
        params.add("client_secret", createClientSecretKey());
        params.add("code", code);
        params.add("redirect_uri", APPLE_REDIRECT_URL);

        RestTemplate restTemplate = new RestTemplate();

        HttpHeaders headers = new HttpHeaders();
        headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
        headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON));
        HttpEntity<MultiValueMap<String, String>> httpEntity = new HttpEntity<>(params, headers);

        try {
            ResponseEntity<String> response = restTemplate.exchange(
                    APPLE_AUTH_URL + "/auth/token",
                    HttpMethod.POST,
                    httpEntity,
                    String.class
            );

            return response.getBody();
        } catch (HttpClientErrorException e) {
            throw new IllegalArgumentException("Apple Auth Token Error");
        }
    }

    public String createClientSecretKey() throws IOException {
        // headerParams 적재
        Map<String, Object> headerParamsMap = new HashMap<>();
        headerParamsMap.put("kid", APPLE_LOGIN_KEY);
        headerParamsMap.put("alg", "ES256");

        // clientSecretKey 생성
        return Jwts
                .builder()
                .setHeaderParams(headerParamsMap)
                .setIssuer(APPLE_TEAM_ID)
                .setIssuedAt(new Date(System.currentTimeMillis()))
                .setExpiration(new Date(System.currentTimeMillis() + 1000 * 30)) // 만료 시간 (30초)
                .setAudience(APPLE_AUTH_URL)
                .setSubject(APPLE_CLIENT_ID)
                .signWith(SignatureAlgorithm.ES256, getPrivateKey())
                .compact();
    }

    private PrivateKey getPrivateKey() throws IOException {
        ClassPathResource resource = new ClassPathResource(APPLE_KEY_PATH);
        String privateKey = new String(resource.getInputStream().readAllBytes());

        Reader pemReader = new StringReader(privateKey);
        PEMParser pemParser = new PEMParser(pemReader);
        JcaPEMKeyConverter converter = new JcaPEMKeyConverter();
        PrivateKeyInfo object = (PrivateKeyInfo) pemParser.readObject();

        return converter.getPrivateKey(object);
    }
}

login(...)

애플 로그인 구현에서 메인 역할을 맡고 있는 메소드이다. 애플로부터 받은 authentication code를 사용하여 auth token을 발급받고 유저 정보를 읽어온다. 읽어온 정보가 기존 DB에 저장되어 있지 않다면 회원가입을 위해 새로 저장해주고, 그렇지 않다면 로그인을 수행한다. 두 경우 모두 JWT access token과 refresh token을 발급해준다.

loginSuccess(...)

로그인 또는 회원가입이 정상적으로 완료된 경우 토큰을 발급하고, response 헤더에 설정한다.

generateAuthToken(...)

authentication code를 사용하여 auth token을 발급받는 메소드이다.

createClientSecretKey(), getPrivateKey()

애플의 client secret을 얻기 위해 사용하는 메소드이다.

참고링크

[Spring Boot]애플 로그인 구현
[Next] Apple 로그인 Spring을 활용하여 구현하기
애플 로그인 및 탈퇴 과정

profile
일단 해보자

0개의 댓글