Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[WALWAL-155] 카카오 로그인/회원가입 구현 #68

Merged
merged 5 commits into from
Aug 3, 2024
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.depromeet.stonebed.domain.auth.application;

import com.depromeet.stonebed.domain.auth.application.apple.AppleClient;
import com.depromeet.stonebed.domain.auth.application.kakao.KakaoClient;
import com.depromeet.stonebed.domain.auth.domain.OAuthProvider;
import com.depromeet.stonebed.domain.auth.dto.RefreshTokenDto;
import com.depromeet.stonebed.domain.auth.dto.request.RefreshTokenRequest;
Expand All @@ -14,7 +15,6 @@
import com.depromeet.stonebed.global.error.ErrorCode;
import com.depromeet.stonebed.global.error.exception.CustomException;
import com.depromeet.stonebed.global.util.MemberUtil;
import java.security.InvalidParameterException;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
Expand All @@ -28,6 +28,7 @@
public class AuthService {

private final AppleClient appleClient;
private final KakaoClient kakaoClient;
private final MemberService memberService;
private final JwtTokenService jwtTokenService;
private final MemberUtil memberUtil;
Expand All @@ -39,9 +40,7 @@ public SocialClientResponse authenticateFromProvider(OAuthProvider provider, Str
*/
return switch (provider) {
case APPLE -> appleClient.authenticateFromApple(token);
// TODO: 추후 카카오 개발 예정
// case KAKAO -> authenticateFromKakao(accessToken);
default -> throw new InvalidParameterException();
case KAKAO -> kakaoClient.authenticateFromKakao(token);
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,14 +50,55 @@ public class AppleClient {

private static final int APPLE_TOKEN_EXPIRE_MINUTES = 5;

/**
* Apple로부터 받은 idToken 검증하고 identifier를 추출합니다.
*
* @param authorizationCode
* @return
*/
public SocialClientResponse authenticateFromApple(String authorizationCode) {
AppleTokenRequest tokenRequest =
AppleTokenRequest.of(
authorizationCode,
appleProperties.dev().clientId(),
generateAppleClientSecret(),
APPLE_GRANT_TYPE);
AppleTokenResponse appleTokenResponse = getAppleToken(tokenRequest);

AppleKeyResponse[] keys = retrieveAppleKeys();
try {
String[] tokenParts = appleTokenResponse.idToken().split("\\.");
String headerPart = new String(Base64.getDecoder().decode(tokenParts[0]));
JsonNode headerNode = objectMapper.readTree(headerPart);
String kid = headerNode.get("kid").asText();
String alg = headerNode.get("alg").asText();

AppleKeyResponse matchedKey =
Arrays.stream(keys)
.filter(key -> key.kid().equals(kid) && key.alg().equals(alg))
.findFirst()
// 일치하는 키가 없음 => 만료된 토큰 or 이상한 토큰 => throw
.orElseThrow(InvalidParameterException::new);

Claims claims = parseIdentifierFromAppleToken(matchedKey, appleTokenResponse.idToken());

String oauthId = claims.get("sub", String.class);
String email = claims.get("email", String.class);

return new SocialClientResponse(email, oauthId);
} catch (Exception ex) {
throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}

// apple server에서 받아온 id_token
private AppleTokenResponse getAppleToken(AppleTokenRequest appleTokenRequest) {
// Prepare form data
MultiValueMap<String, String> formData = new LinkedMultiValueMap<>();
formData.add("client_id", appleTokenRequest.client_id());
formData.add("client_secret", appleTokenRequest.client_secret());
formData.add("client_id", appleTokenRequest.clientId());
formData.add("client_secret", appleTokenRequest.clientSecret());
formData.add("code", appleTokenRequest.code());
formData.add("grant_type", appleTokenRequest.grant_type());
formData.add("grant_type", appleTokenRequest.grantType());

return restClient
.post()
Expand Down Expand Up @@ -107,48 +148,6 @@ private PrivateKey getPrivateKey() {
}
}

/**
* Apple로부터 받은 idToken 검증하고 identifier를 추출합니다.
*
* @param authorizationCode
* @return
*/
public SocialClientResponse authenticateFromApple(String authorizationCode) {
AppleTokenRequest tokenRequest =
AppleTokenRequest.of(
authorizationCode,
appleProperties.dev().clientId(),
generateAppleClientSecret(),
APPLE_GRANT_TYPE);
AppleTokenResponse appleTokenResponse = getAppleToken(tokenRequest);

AppleKeyResponse[] keys = retrieveAppleKeys();
try {
String[] tokenParts = appleTokenResponse.id_token().split("\\.");
String headerPart = new String(Base64.getDecoder().decode(tokenParts[0]));
JsonNode headerNode = objectMapper.readTree(headerPart);
String kid = headerNode.get("kid").asText();
String alg = headerNode.get("alg").asText();

AppleKeyResponse matchedKey =
Arrays.stream(keys)
.filter(key -> key.kid().equals(kid) && key.alg().equals(alg))
.findFirst()
// 일치하는 키가 없음 => 만료된 토큰 or 이상한 토큰 => throw
.orElseThrow(InvalidParameterException::new);

Claims claims =
parseIdentifierFromAppleToken(matchedKey, appleTokenResponse.id_token());

String oauthId = claims.get("sub", String.class);
String email = claims.get("email", String.class);

return new SocialClientResponse(email, oauthId);
} catch (Exception ex) {
throw new CustomException(ErrorCode.INTERNAL_SERVER_ERROR);
}
}

private AppleKeyResponse[] retrieveAppleKeys() {
AppleKeyListResponse keyListResponse =
restClient
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package com.depromeet.stonebed.domain.auth.application.kakao;

import static com.depromeet.stonebed.global.common.constants.SecurityConstants.*;

import com.depromeet.stonebed.domain.auth.dto.response.KakaoAuthResponse;
import com.depromeet.stonebed.domain.auth.dto.response.SocialClientResponse;
import com.depromeet.stonebed.global.error.ErrorCode;
import com.depromeet.stonebed.global.error.exception.CustomException;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;

@Component
@RequiredArgsConstructor
public class KakaoClient {
private final RestClient restClient;

public SocialClientResponse authenticateFromKakao(String token) {
KakaoAuthResponse kakaoAuthResponse =
restClient
.get()
.uri(KAKAO_USER_ME_URL)
.header("Authorization", TOKEN_PREFIX + token)
.exchange(
(request, response) -> {
if (!response.getStatusCode().is2xxSuccessful()) {
throw new CustomException(
ErrorCode.APPLE_TOKEN_CLIENT_FAILED);
}
return Objects.requireNonNull(
response.bodyTo(KakaoAuthResponse.class));
});

return new SocialClientResponse(
kakaoAuthResponse.kakaoAccount().email(), kakaoAuthResponse.id().toString());
}
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
package com.depromeet.stonebed.domain.auth.dto.request;

import com.fasterxml.jackson.annotation.JsonProperty;

public record AppleTokenRequest(
// 외부 통신 시 snake_case로 요청 및 응답
String code, String client_id, String client_secret, String grant_type) {
@JsonProperty("code") String code,
@JsonProperty("client_id") String clientId,
@JsonProperty("client_secret") String clientSecret,
@JsonProperty("grant_type") String grantType) {
public static AppleTokenRequest of(
String code, String clientId, String clientSecret, String grantType) {
return new AppleTokenRequest(code, clientId, clientSecret, grantType);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
package com.depromeet.stonebed.domain.auth.dto.response;

import com.fasterxml.jackson.annotation.JsonProperty;

public record AppleTokenResponse(
// 외부 통신 시 snake_case로 요청 및 응답
String access_token,
Long expires_in,
String id_token,
String refresh_token,
String token_type) {
@JsonProperty("access_token") String accessToken,
@JsonProperty("expires_in") Long expiresIn,
@JsonProperty("id_token") String idToken,
@JsonProperty("refresh_token") String refreshToken,
@JsonProperty("token_type") String tokenType) {
public static AppleTokenResponse of(
String accessToken,
Long expiresIn,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.depromeet.stonebed.domain.auth.dto.response;

import com.fasterxml.jackson.annotation.JsonProperty;

public record KakaoAuthResponse(
Long id,
@JsonProperty("kakao_account") KakaoAccountResponse kakaoAccount,
@JsonProperty("properties") PropertiesResponse properties) {
public static record KakaoAccountResponse(String email) {}

public static record PropertiesResponse(
String nickname, String profile_image, String thumbnail_image) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@

public final class SecurityConstants {

public static final String TOKEN_ROLE_NAME = "role";
public static final String TOKEN_PREFIX = "Bearer ";
// apple
public static final String APPLE_JWK_SET_URL = "https://appleid.apple.com/auth/keys";
public static final String APPLE_ISSUER = "https://appleid.apple.com";
public static final String APPLE_TOKEN_URL = "https://appleid.apple.com/auth/token";
public static final String APPLE_GRANT_TYPE = "authorization_code";

// kakao
public static final String KAKAO_USER_ME_URL = "https://kapi.kakao.com/v2/user/me";

// security
public static final String TOKEN_ROLE_NAME = "role";
public static final String TOKEN_PREFIX = "Bearer ";
public static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken";
public static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken";
public static final String APPLICATION_URLENCODED = "application/x-www-form-urlencoded";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ public enum ErrorCode {
APPLE_TOKEN_CLIENT_FAILED(HttpStatus.BAD_REQUEST, "애플 토큰 생성에 실패했습니다."),
APPLE_PRIVATE_KEY_ENCODING_FAILED(HttpStatus.BAD_REQUEST, "애플 개인키 인코딩에 실패했습니다."),

// kakao client
KAKAO_TOKEN_CLIENT_FAILED(HttpStatus.BAD_REQUEST, "카카오 통신에 실패했습니다."),

// member
MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 회원을 찾을 수 없습니다."),
ALREADY_EXISTS_MEMBER(HttpStatus.CONFLICT, "이미 존재하는 회원입니다."),
Expand Down
Loading