diff --git a/build.gradle b/build.gradle index 984d7c9..77b3104 100644 --- a/build.gradle +++ b/build.gradle @@ -52,6 +52,9 @@ dependencies { // Gson implementation 'com.google.code.gson:gson' + + // Goolge OAuth2 + implementation 'com.google.api-client:google-api-client:2.8.0' // Apache Commons Lang // implementation 'org.apache.commons:commons-lang3:3.12.0' diff --git a/src/main/java/com/ikdaman/domain/auth/controller/AuthController.java b/src/main/java/com/ikdaman/domain/auth/controller/AuthController.java index cc46fb0..9dfa36c 100644 --- a/src/main/java/com/ikdaman/domain/auth/controller/AuthController.java +++ b/src/main/java/com/ikdaman/domain/auth/controller/AuthController.java @@ -29,12 +29,12 @@ public class AuthController { /** * 소셜 로그인 * @param dto - * @param socialToken + * @param socialToken access-token: 카카오, 네이버 | id-token: 구글, 애플 * @return */ @PostMapping("/login") public ResponseEntity socialLogin(@RequestBody AuthReq dto, - @RequestHeader("social-access-token") String socialToken) { + @RequestHeader("social-token") String socialToken) { String provider = dto.getProvider().toLowerCase(); OAuthService oAuthService = socialLoginServices.get(provider); diff --git a/src/main/java/com/ikdaman/domain/auth/service/GoogleAuthService.java b/src/main/java/com/ikdaman/domain/auth/service/GoogleAuthService.java index 7a8641b..7d6d3fd 100644 --- a/src/main/java/com/ikdaman/domain/auth/service/GoogleAuthService.java +++ b/src/main/java/com/ikdaman/domain/auth/service/GoogleAuthService.java @@ -16,6 +16,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; +import static com.ikdaman.global.exception.ErrorCode.GOOGLE_SERVER_ERROR; import static com.ikdaman.global.exception.ErrorCode.INVALID_SOCIAL_ACCESS_TOKEN; @Service("google") @@ -34,10 +35,53 @@ public class GoogleAuthService implements OAuthService { @Override @Transactional - public AuthRes login(AuthReq dto, String socialAccessToken) { + public AuthRes login(AuthReq dto, String socialToken) { + + // 1. 소셜 idToken을 통해 Google userId 가져오기 + final String checkProviderId; + try { + checkProviderId = clientGoogle.getUserDataByIdToken(socialToken); + } catch (Exception e) { + throw new BaseException(GOOGLE_SERVER_ERROR); + } + + // 2. 요청으로 들어온 providerId와 비교해서 검증 + if (!dto.getProviderId().equals(checkProviderId)) throw new BaseException(INVALID_SOCIAL_ACCESS_TOKEN); + + // 3. 기존 회원 조회 + Member member = memberRepository.findBySocialTypeAndProviderId(Member.SocialType.GOOGLE, checkProviderId) + .orElseGet(() -> { + String nickname; + do { + nickname = randomNickname.generate(); + } while (!memberService.isAvailableNickname(nickname)); // 닉네임 중복되면 다시 생성 + + // 유저 정보 저장 + Member newMember = Member.builder() + .socialType(Member.SocialType.GOOGLE) + .providerId(checkProviderId) + .nickname(nickname) + .build(); + return memberRepository.save(newMember); + }); + + // 3. 신규 토큰 생성 및 저장 + AuthToken accessToken = authTokenProvider.createUserAppToken(String.valueOf(member.getMemberId())); + AuthToken refreshToken = authTokenProvider.createRefreshToken(String.valueOf(member.getMemberId())); + redisService.setValuesWithTimeout(String.valueOf(member.getMemberId()), refreshToken.getToken(), refreshExpiry); + + return AuthRes.builder() + .accessToekn(accessToken.getToken()) + .refreshToken(refreshToken.getToken()) + .nickname(member.getNickname()) + .build(); + } + + @Transactional + public AuthRes loginByAccessToken(AuthReq dto, String socialToken) { // 1. 소셜 accessToken을 통해 Google userId 가져오기 - String checkProviderId = clientGoogle.getUserData(socialAccessToken); + String checkProviderId = clientGoogle.getUserDataByAccessToken(socialToken); // 2. 요청으로 들어온 providerId와 비교해서 검증 if (!dto.getProviderId().equals(checkProviderId)) throw new BaseException(INVALID_SOCIAL_ACCESS_TOKEN); diff --git a/src/main/java/com/ikdaman/domain/auth/service/OAuthService.java b/src/main/java/com/ikdaman/domain/auth/service/OAuthService.java index 39bdde4..9377c72 100644 --- a/src/main/java/com/ikdaman/domain/auth/service/OAuthService.java +++ b/src/main/java/com/ikdaman/domain/auth/service/OAuthService.java @@ -4,5 +4,5 @@ import com.ikdaman.domain.auth.model.AuthRes; public interface OAuthService { - AuthRes login(AuthReq dto, String socialAccessToken); + AuthRes login(AuthReq dto, String socialToken); } \ No newline at end of file diff --git a/src/main/java/com/ikdaman/global/auth/client/ClientGoogle.java b/src/main/java/com/ikdaman/global/auth/client/ClientGoogle.java index e639c91..2844fe7 100644 --- a/src/main/java/com/ikdaman/global/auth/client/ClientGoogle.java +++ b/src/main/java/com/ikdaman/global/auth/client/ClientGoogle.java @@ -1,12 +1,19 @@ package com.ikdaman.global.auth.client; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; import com.ikdaman.global.auth.payload.OAuthUserRes; import com.ikdaman.global.exception.BaseException; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; +import java.util.Arrays; + import static com.ikdaman.global.exception.ErrorCode.GOOGLE_SERVER_ERROR; import static com.ikdaman.global.exception.ErrorCode.INVALID_SOCIAL_ACCESS_TOKEN; @@ -14,18 +21,35 @@ @RequiredArgsConstructor public class ClientGoogle { + @Value("${auth.google.client-id.android}") + private String androidClientId; + + @Value("${auth.google.client-id.ios}") + private String iosClientId; + + @Value("${auth.google.client-id.web}") + private String webClientId; + private final WebClient webClient; - // TODO: HttpHeaders.AUTHORIZATION 사용하도록 변경 - public String getUserData(String accessToken) { + /** + * accessToken을 사용하여 Google의 userinfo API에서 사용자 정보 조회 + * + * @param accessToken Google accessToken + * @return 사용자 Google 계정의 providerId + */ + public String getUserDataByAccessToken(String accessToken) { + + // WebClient를 사용해 Google API 호출 OAuthUserRes OAuthUserRes = webClient.get() - .uri("https://www.googleapis.com/oauth2/v2/userinfo") // Google의 유저 정보 받아오는 url + .uri("https://www.googleapis.com/oauth2/v2/userinfo") // Google 사용자 정보 요청 URL .header("Authorization", "Bearer " + accessToken) // .headers(h -> h.setBearerAuth("U-" + accessToken)) // 임시로 발급받은 사용자 토큰으로 접근 .retrieve() - // onStatus <- error handling + // 4xx 에러 처리 .onStatus(status -> status.is4xxClientError(), response -> Mono.error(new BaseException(INVALID_SOCIAL_ACCESS_TOKEN))) + // 5xx 에러 처리 .onStatus(status -> status.is5xxServerError(), response -> Mono.error(new BaseException(GOOGLE_SERVER_ERROR))) .bodyToMono(OAuthUserRes.class) // 유저 정보를 넣을 DTO 클래스 @@ -33,4 +57,35 @@ public String getUserData(String accessToken) { return String.valueOf(OAuthUserRes.getId()); } + + /** + * idToken을 검증 및 파싱하여 Google의 사용자 정보 추출 + * + * @param idToken Google idToken + * @return 사용자 Google 계정의 providerId + */ + public String getUserDataByIdToken(String idToken) throws Exception { + + // GoogleIdTokenVerifier를 생성: Google의 공개키로 idToken의 서명을 검증 + GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder( + GoogleNetHttpTransport.newTrustedTransport(), + GsonFactory.getDefaultInstance() + ) + // idToken의 Audience 설정: 웹, Android, iOS client ID를 모두 허용 + .setAudience(Arrays.asList( + webClientId, + androidClientId, + iosClientId + )) + .build(); + + GoogleIdToken googleIdToken = verifier.verify(idToken); + if (googleIdToken == null) { + throw new BaseException(INVALID_SOCIAL_ACCESS_TOKEN); + } + + // Payload에서 Google의 "sub" (providerId) 추출 + GoogleIdToken.Payload payload = googleIdToken.getPayload(); + return payload.getSubject(); + } } diff --git a/src/main/java/com/ikdaman/global/auth/token/AuthToken.java b/src/main/java/com/ikdaman/global/auth/token/AuthToken.java index 98aff00..1a8626b 100644 --- a/src/main/java/com/ikdaman/global/auth/token/AuthToken.java +++ b/src/main/java/com/ikdaman/global/auth/token/AuthToken.java @@ -1,6 +1,7 @@ package com.ikdaman.global.auth.token; import com.ikdaman.global.auth.enumerate.RoleType; +import com.ikdaman.global.exception.BaseException; import io.jsonwebtoken.*; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -9,6 +10,12 @@ import java.security.Key; import java.util.Date; +import static com.ikdaman.global.exception.ErrorCode.EXPIRED_ACCESS_TOKEN; +import static com.ikdaman.global.exception.ErrorCode.INVALID_ACCESS_TOKEN; +import static com.ikdaman.global.exception.ErrorCode.INVALID_ACCESS_TOKEN_FORMAT; +import static com.ikdaman.global.exception.ErrorCode.INVALID_ACCESS_TOKEN_SIGNATURE; +import static com.ikdaman.global.exception.ErrorCode.UNSUPPORTED_ACCESS_TOKEN; + @Slf4j @RequiredArgsConstructor public class AuthToken { @@ -48,20 +55,18 @@ public Claims getTokenClaims() { .parseClaimsJws(token) .getBody(); // token의 Body가 다음의 exception들로 인해 유효하지 않으면 각각의 로그를 콘솔에 출력 - // TODO: Error 코드 세분화 필요 } catch (SecurityException e) { - log.info("Invalid JWT signature."); + throw new BaseException(INVALID_ACCESS_TOKEN_SIGNATURE); } catch (MalformedJwtException e) { // 처음 로그인(/auth/kakao) 할 때, AccessToken(여기선 appToken) 없이 접근해도 token validate 체크 // -> exception 터트리지 않고 catch로 잡아줌 - log.info("Invalid JWT token."); + throw new BaseException(INVALID_ACCESS_TOKEN_FORMAT); } catch (ExpiredJwtException e) { - log.info("Expired JWT token."); + throw new BaseException(EXPIRED_ACCESS_TOKEN); } catch (UnsupportedJwtException e) { - log.info("Unsupported JWT token."); + throw new BaseException(UNSUPPORTED_ACCESS_TOKEN); } catch (IllegalArgumentException e) { - log.info("JWT token compact of handler are invalid."); + throw new BaseException(INVALID_ACCESS_TOKEN); } - return null; } } \ No newline at end of file diff --git a/src/main/java/com/ikdaman/global/exception/ErrorCode.java b/src/main/java/com/ikdaman/global/exception/ErrorCode.java index 265becf..35658a6 100644 --- a/src/main/java/com/ikdaman/global/exception/ErrorCode.java +++ b/src/main/java/com/ikdaman/global/exception/ErrorCode.java @@ -37,7 +37,11 @@ public enum ErrorCode { INVALID_ACCESS_TOKEN(HttpStatus.NOT_FOUND.value(), 4040102,"Access Token이 유효하지 않습니다."), INVALID_REFRESH_TOKEN(HttpStatus.NOT_FOUND.value(), 4040103,"Refresh Token이 유효하지 않습니다."), INVALID_SOCIAL_PROVIDER(HttpStatus.NOT_FOUND.value(), 4040104,"유효하지 않은 소셜 로그인 제공업체입니다."), - NOT_MATCH_TOKEN_PROVIDER(HttpStatus.NOT_FOUND.value(), 4040101,"Social Access Token과 Provider Id가 매칭되지 않습니다."), + NOT_MATCH_TOKEN_PROVIDER(HttpStatus.NOT_FOUND.value(), 4040105,"Social Access Token과 Provider Id가 매칭되지 않습니다."), + INVALID_ACCESS_TOKEN_SIGNATURE(HttpStatus.UNAUTHORIZED.value(), 4010106, "Access Token의 서명이 유효하지 않습니다."), + INVALID_ACCESS_TOKEN_FORMAT(HttpStatus.UNAUTHORIZED.value(), 4010107, "Access Token의 형식이 유효하지 않습니다."), + EXPIRED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED.value(), 4010108, "Access Token이 만료되었습니다."), + UNSUPPORTED_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED.value(), 4010109, "Access Token이 지원되지 않습니다."), // Member(02) NOT_FOUND_USER(HttpStatus.NOT_FOUND.value(), 4040201, "존재하지 않는 유저입니다."),