From 2f9b3f0603c46c024f4938b34899816537c50357 Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Thu, 11 Dec 2025 21:59:14 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat=20:=20google=20login=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84=20=EC=99=84=EB=A3=8C=20(ios=20=EA=B5=AC=ED=98=84=20?= =?UTF-8?q?=EC=A4=91)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 4 +- .../auth/controller/AuthController.java | 46 +++++- .../dto/request/GoogleAndAppleLoginReq.java | 7 + .../request/GoogleAndAppleSignupRequest.java | 12 ++ .../domain/auth/service/AuthService.java | 153 +++++++++++++++++- .../domain/user/constant/LoginType.java | 4 +- .../domain/user/constant/PlatformType.java | 6 + .../ita/tinybite/domain/user/entity/User.java | 10 ++ .../tinybite/global/config/AppleConfig.java | 19 +++ .../exception/errorcode/AuthErrorCode.java | 6 +- src/main/resources/application-local.yaml | 8 + 11 files changed, 261 insertions(+), 14 deletions(-) create mode 100644 src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleLoginReq.java create mode 100644 src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java create mode 100644 src/main/java/ita/tinybite/domain/user/constant/PlatformType.java create mode 100644 src/main/java/ita/tinybite/global/config/AppleConfig.java diff --git a/build.gradle b/build.gradle index 7c91ca6..d699dd5 100644 --- a/build.gradle +++ b/build.gradle @@ -28,7 +28,6 @@ dependencies { // spring starter implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' - implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' //security @@ -63,6 +62,9 @@ dependencies { // sms implementation 'com.solapi:sdk:1.0.3' + + // google login + implementation 'com.google.api-client:google-api-client:2.7.2' } tasks.named('test') { diff --git a/src/main/java/ita/tinybite/domain/auth/controller/AuthController.java b/src/main/java/ita/tinybite/domain/auth/controller/AuthController.java index 30288dc..d220452 100644 --- a/src/main/java/ita/tinybite/domain/auth/controller/AuthController.java +++ b/src/main/java/ita/tinybite/domain/auth/controller/AuthController.java @@ -1,8 +1,6 @@ package ita.tinybite.domain.auth.controller; -import ita.tinybite.domain.auth.dto.request.KakaoLoginRequest; -import ita.tinybite.domain.auth.dto.request.KakaoSignupRequest; -import ita.tinybite.domain.auth.dto.request.RefreshTokenRequest; +import ita.tinybite.domain.auth.dto.request.*; import ita.tinybite.domain.auth.dto.response.AuthResponse; import ita.tinybite.domain.auth.service.AuthService; import ita.tinybite.global.response.APIResponse; @@ -18,6 +16,8 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.util.UriComponentsBuilder; +import static ita.tinybite.global.response.APIResponse.success; + @Slf4j @RestController @RequestMapping("/api/v1/auth") @@ -55,7 +55,7 @@ public ResponseEntity> kakaoSignup( ) { AuthResponse response = authService.kakaoSignup(request); return ResponseEntity.status(HttpStatus.CREATED) - .body(APIResponse.success(response)); + .body(success(response)); } @PostMapping("/kakao/login") @@ -63,7 +63,39 @@ public ResponseEntity> kakaoLogin( @Valid @RequestBody KakaoLoginRequest request ) { AuthResponse response = authService.kakaoLogin(request); - return ResponseEntity.ok(APIResponse.success(response)); + return ResponseEntity.ok(success(response)); + } + + @PostMapping("/google/signup") + public ResponseEntity> googleSignup( + @Valid @RequestBody GoogleAndAppleSignupRequest req + ) { + return ResponseEntity + .status(HttpStatus.CREATED) + .body(success(authService.googleSignup(req))); + } + + @PostMapping("/google/login") + public APIResponse googleLogin( + @Valid @RequestBody GoogleAndAppleLoginReq req + ) { + return success(authService.googleLogin(req)); + } + + @PostMapping("/apple/signup") + public ResponseEntity> appleSignup( + @Valid @RequestBody GoogleAndAppleSignupRequest req + ) { + return ResponseEntity + .status(HttpStatus.CREATED) + .body(success(authService.appleSignup(req))); + } + + @PostMapping("/apple/login") + public APIResponse appleLogin( + @Valid @RequestBody GoogleAndAppleLoginReq req + ) { + return success(authService.appleLogin(req)); } @PostMapping("/refresh") @@ -71,7 +103,7 @@ public ResponseEntity> refreshToken( @Valid @RequestBody RefreshTokenRequest request ) { AuthResponse response = authService.refreshToken(request); - return ResponseEntity.ok(APIResponse.success(response)); + return ResponseEntity.ok(success(response)); } @PostMapping("/logout") @@ -79,7 +111,7 @@ public ResponseEntity> logout( @RequestAttribute("userId") Long userId ) { authService.logout(userId); - return ResponseEntity.ok(APIResponse.success(null)); + return ResponseEntity.ok(success(null)); } } diff --git a/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleLoginReq.java b/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleLoginReq.java new file mode 100644 index 0000000..5478216 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleLoginReq.java @@ -0,0 +1,7 @@ +package ita.tinybite.domain.auth.dto.request; + +import ita.tinybite.domain.user.constant.PlatformType; + +public record GoogleAndAppleLoginReq(String idToken, + PlatformType platformType) { +} diff --git a/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java b/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java new file mode 100644 index 0000000..f9d0fe8 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java @@ -0,0 +1,12 @@ +package ita.tinybite.domain.auth.dto.request; + +import ita.tinybite.domain.user.constant.PlatformType; + +public record GoogleAndAppleSignupRequest( + String idToken, + String phone, + String nickname, + String location, + PlatformType platform +) { +} diff --git a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java index 0b20c0c..89a9314 100644 --- a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java +++ b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java @@ -1,8 +1,10 @@ package ita.tinybite.domain.auth.service; -import ita.tinybite.domain.auth.dto.request.KakaoLoginRequest; -import ita.tinybite.domain.auth.dto.request.KakaoSignupRequest; -import ita.tinybite.domain.auth.dto.request.RefreshTokenRequest; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken; +import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.api.client.json.gson.GsonFactory; +import ita.tinybite.domain.auth.dto.request.*; import ita.tinybite.domain.auth.dto.response.AuthResponse; import ita.tinybite.domain.auth.dto.response.UserDto; import ita.tinybite.domain.auth.entity.JwtTokenProvider; @@ -11,17 +13,27 @@ import ita.tinybite.domain.auth.kakao.KakaoApiClient.KakaoUserInfo; import ita.tinybite.domain.auth.repository.RefreshTokenRepository; import ita.tinybite.domain.user.constant.LoginType; +import ita.tinybite.domain.user.constant.PlatformType; import ita.tinybite.domain.user.constant.UserStatus; import ita.tinybite.domain.user.entity.User; import ita.tinybite.domain.user.repository.UserRepository; +import ita.tinybite.global.exception.BusinessException; +import ita.tinybite.global.exception.errorcode.AuthErrorCode; +import ita.tinybite.global.exception.errorcode.UserErrorCode; import ita.tinybite.global.util.NicknameGenerator; -import jakarta.transaction.Transactional; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import java.io.*; +import java.security.GeneralSecurityException; import java.time.LocalDateTime; +import java.util.Collections; @Slf4j @Service @@ -32,8 +44,18 @@ public class AuthService { private final RefreshTokenRepository refreshTokenRepository; private final KakaoApiClient kakaoApiClient; private final JwtTokenProvider jwtTokenProvider; + private final JwtDecoder appleJwtDecoder; private final NicknameGenerator nicknameGenerator; +// @Value("${apple.client-id}") +// private String appleClientId; + + @Value("${google.android-id}") + private String googleAndroidId; + + @Value("${google.ios-id}") + private String googleIosId; + @Transactional public AuthResponse kakaoSignup(KakaoSignupRequest request) { // 카카오 API로 유저 정보 조회 @@ -109,6 +131,129 @@ public AuthResponse kakaoLogin(KakaoLoginRequest request) { .build(); } + @Transactional + public AuthResponse googleSignup(@Valid GoogleAndAppleSignupRequest req) { + // idToken으로 이메일 추출 + String email = getEmailFromIdToken(req.idToken(), req.platform(), LoginType.GOOGLE); + + // 해당 이메일의 유저 find + User user = userRepository.findByEmail(email) + .orElseThrow(() -> BusinessException.of(UserErrorCode.USER_NOT_EXISTS)); + + // req필드로 유저 필드 업데이트 -> 실질적 회원가입 + user.updateSignupInfo(req, email); + userRepository.save(user); + + return getAuthResponse(user); + } + + @Transactional(readOnly = true) + public AuthResponse googleLogin(@Valid GoogleAndAppleLoginReq req) { + // idToken으로 이메일 추출 + String email = getEmailFromIdToken(req.idToken(), req.platformType(), LoginType.GOOGLE); + // 해당 이메일로 유저 찾은 후 응답 반환 (accessToken, refreshToken) + return getUser(email); + } + + @Transactional + public AuthResponse appleSignup(@Valid GoogleAndAppleSignupRequest req) { + String email = getEmailFromIdToken(req.idToken(), req.platform(), LoginType.APPLE); + + // 해당 이메일의 유저 find + User user = userRepository.findByEmail(email) + .orElseThrow(() -> BusinessException.of(UserErrorCode.USER_NOT_EXISTS)); + + // req필드로 유저 필드 업데이트 -> 실질적 회원가입 + user.updateSignupInfo(req, email); + userRepository.save(user); + + return getAuthResponse(user); + } + + @Transactional(readOnly = true) + public AuthResponse appleLogin(@Valid GoogleAndAppleLoginReq req) { + // idToken으로 이메일 추출 + String email = getEmailFromIdToken(req.idToken(), req.platformType(), LoginType.APPLE); + // 해당 이메일로 유저 찾은 후 응답 반환 (AuthResponse) + return getUser(email); + } + + private AuthResponse getAuthResponse(User user) { + // 4. JWT 토큰 생성 + String accessToken = jwtTokenProvider.generateAccessToken(user); + String refreshToken = jwtTokenProvider.generateRefreshToken(user); + + // 5. 기존 Refresh Token 삭제 후 새로 저장 + refreshTokenRepository.deleteByUserId(user.getUserId()); + saveRefreshToken(user.getUserId(), refreshToken); + + log.info("로그인 성공 - User ID: {}, Email: {}", user.getUserId(), user.getEmail()); + + // 6. 응답 생성 + return AuthResponse.builder() + .accessToken(accessToken) + .refreshToken(refreshToken) + .tokenType("Bearer") + .expiresIn(3600L) + .user(UserDto.from(user)) + .build(); + } + + // 구글과 애플 통합 + private String getEmailFromIdToken(String idToken, PlatformType platformType, LoginType loginType) { + switch(loginType) { + case GOOGLE -> { + + String clientId = switch (platformType) { + case ANDROID -> googleAndroidId; + case IOS -> googleIosId; + }; + + try { + GoogleIdTokenVerifier googleVerifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), new GsonFactory()) + .setAudience(Collections.singletonList(clientId)) + .build(); + + GoogleIdToken token = googleVerifier.verify(idToken); + + if(token == null) { + throw BusinessException.of(AuthErrorCode.INVALID_TOKEN); + } + + return token.getPayload().getEmail(); + + } catch (GeneralSecurityException | IOException e) { + throw BusinessException.of(AuthErrorCode.GOOGLE_LOGIN_ERROR); + } + } + case APPLE -> { + //TODO Apple 구현 예정 + } + } + return null; + } + + private AuthResponse getUser(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> { + // 이메일로 가입된 유저가 없을 시, INACTIVE로 임시 생성 + // 회원가입 시 해당 임시 유저를 통해 마저 회원가입 진행 + userRepository.save(User.builder() + .email(email) + .status(UserStatus.INACTIVE) + .build()); + + return BusinessException.of(UserErrorCode.USER_NOT_EXISTS); + }); + + // 3. 탈퇴한 사용자 체크 + if (user.getStatus() == UserStatus.WITHDRAW) { + throw new RuntimeException("탈퇴한 사용자입니다."); + } + + return getAuthResponse(user); + } + @Transactional public AuthResponse refreshToken(RefreshTokenRequest request) { String refreshTokenValue = request.getRefreshToken(); diff --git a/src/main/java/ita/tinybite/domain/user/constant/LoginType.java b/src/main/java/ita/tinybite/domain/user/constant/LoginType.java index 09bc6b7..796e8d5 100644 --- a/src/main/java/ita/tinybite/domain/user/constant/LoginType.java +++ b/src/main/java/ita/tinybite/domain/user/constant/LoginType.java @@ -1,5 +1,7 @@ package ita.tinybite.domain.user.constant; public enum LoginType { - KAKAO, GOOGLE + KAKAO, GOOGLE, APPLE + + ; } diff --git a/src/main/java/ita/tinybite/domain/user/constant/PlatformType.java b/src/main/java/ita/tinybite/domain/user/constant/PlatformType.java new file mode 100644 index 0000000..36b0f29 --- /dev/null +++ b/src/main/java/ita/tinybite/domain/user/constant/PlatformType.java @@ -0,0 +1,6 @@ +package ita.tinybite.domain.user.constant; + +public enum PlatformType { + ANDROID, + IOS, +} diff --git a/src/main/java/ita/tinybite/domain/user/entity/User.java b/src/main/java/ita/tinybite/domain/user/entity/User.java index 8672f0c..d6526a3 100644 --- a/src/main/java/ita/tinybite/domain/user/entity/User.java +++ b/src/main/java/ita/tinybite/domain/user/entity/User.java @@ -1,5 +1,6 @@ package ita.tinybite.domain.user.entity; +import ita.tinybite.domain.auth.dto.request.GoogleAndAppleSignupRequest; import ita.tinybite.domain.user.constant.LoginType; import ita.tinybite.domain.user.constant.UserStatus; import ita.tinybite.domain.user.dto.req.UpdateUserReqDto; @@ -49,4 +50,13 @@ public void update(UpdateUserReqDto req) { public void updateLocation(String location) { this.location = location; } + + public void updateSignupInfo(GoogleAndAppleSignupRequest req, String email) { + this.email = (email); + this.nickname = (req.nickname()); + this.phone = (req.phone()); + this.location = (req.location()); + this.status = UserStatus.ACTIVE; + this.type = LoginType.GOOGLE; + } } diff --git a/src/main/java/ita/tinybite/global/config/AppleConfig.java b/src/main/java/ita/tinybite/global/config/AppleConfig.java new file mode 100644 index 0000000..0f82b12 --- /dev/null +++ b/src/main/java/ita/tinybite/global/config/AppleConfig.java @@ -0,0 +1,19 @@ +package ita.tinybite.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; + +@Configuration +public class AppleConfig { + + private static final String APPLE_JWK_URL = "https://appleid.apple.com/auth/keys"; + + @Bean + public JwtDecoder appleJwtDecoder() { + return NimbusJwtDecoder + .withJwkSetUri(APPLE_JWK_URL) + .build(); + } +} diff --git a/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java index 00c7180..5e06b74 100644 --- a/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java +++ b/src/main/java/ita/tinybite/global/exception/errorcode/AuthErrorCode.java @@ -12,7 +12,11 @@ public enum AuthErrorCode implements ErrorCode { DUPLICATED_NICKNAME(HttpStatus.BAD_REQUEST, "DUPLICATED_NICKNAME", "중복된 닉네임입니다."), - ; + INVALID_TOKEN(HttpStatus.BAD_REQUEST, "INVALID_TOKEN", "유효하지 않은 토큰입니다."), + GOOGLE_LOGIN_ERROR(HttpStatus.BAD_REQUEST, "GOOGLE_LOGIN_ERROR", "구글 로그인 중 에러가 발생했습니다."), + APPLE_LOGIN_ERROR(HttpStatus.BAD_REQUEST, "APPLE_LOGIN_ERROR", "애플 로그인 중 에러가 발생했습니다."), + + INVALID_PLATFORM(HttpStatus.BAD_REQUEST, "INVALID_PLATFORM", "올바른 플랫폼이 아닙니다. (Android, iOS)"); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 9de545a..4148ea2 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -33,6 +33,14 @@ naver: client-id: ${NAVER_CLIENT_ID} secret: ${NAVER_CLIENT_SECRET} +google: + android-id: ${GOOGLE_ANDROID_CLIENT_ID} + ios-id: ${GOOGLE_IOS_CLIENT_ID} + +#apple: +# client-id: ${APPLE_CLIENT_ID} +# + logging: level: org.hibernate.SQL: debug \ No newline at end of file From a258e51419aef387350ed164b63532ebd315582a Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Thu, 11 Dec 2025 22:30:40 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix=20:=20=EC=88=98=EC=A0=95=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/request/GoogleAndAppleSignupRequest.java | 6 ++++++ .../java/ita/tinybite/domain/auth/service/AuthService.java | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java b/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java index f9d0fe8..d84585a 100644 --- a/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java +++ b/src/main/java/ita/tinybite/domain/auth/dto/request/GoogleAndAppleSignupRequest.java @@ -1,12 +1,18 @@ package ita.tinybite.domain.auth.dto.request; import ita.tinybite.domain.user.constant.PlatformType; +import jakarta.validation.constraints.NotBlank; public record GoogleAndAppleSignupRequest( + @NotBlank(message = "idToken은 필수입니다") String idToken, + @NotBlank(message = "전화번호는 필수입니다") String phone, + @NotBlank(message = "닉네임은 필수입니다") String nickname, + @NotBlank(message = "위치 정보 필수입니다") String location, + @NotBlank(message = "플랫폼정보는 필수입니다") PlatformType platform ) { } diff --git a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java index 89a9314..0945701 100644 --- a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java +++ b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java @@ -147,7 +147,7 @@ public AuthResponse googleSignup(@Valid GoogleAndAppleSignupRequest req) { return getAuthResponse(user); } - @Transactional(readOnly = true) + @Transactional public AuthResponse googleLogin(@Valid GoogleAndAppleLoginReq req) { // idToken으로 이메일 추출 String email = getEmailFromIdToken(req.idToken(), req.platformType(), LoginType.GOOGLE); @@ -170,7 +170,7 @@ public AuthResponse appleSignup(@Valid GoogleAndAppleSignupRequest req) { return getAuthResponse(user); } - @Transactional(readOnly = true) + @Transactional public AuthResponse appleLogin(@Valid GoogleAndAppleLoginReq req) { // idToken으로 이메일 추출 String email = getEmailFromIdToken(req.idToken(), req.platformType(), LoginType.APPLE); From 0cd921fca00ceb368e94560ed24c42d9e31fda7c Mon Sep 17 00:00:00 2001 From: Wonjae Lim Date: Thu, 11 Dec 2025 23:36:15 +0900 Subject: [PATCH 3/3] fix : user hard delete --- src/main/java/ita/tinybite/domain/auth/service/AuthService.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java index 0945701..4a3d504 100644 --- a/src/main/java/ita/tinybite/domain/auth/service/AuthService.java +++ b/src/main/java/ita/tinybite/domain/auth/service/AuthService.java @@ -293,6 +293,7 @@ public AuthResponse refreshToken(RefreshTokenRequest request) { @Transactional public void logout(Long userId) { refreshTokenRepository.deleteByUserId(userId); + userRepository.deleteById(userId); log.info("로그아웃 - User ID: {}", userId); }