diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 61e86a1a..8cb03eb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,10 @@ jobs: KAKAO_CLIENT_SECRET: ${{ secrets.KAKAO_CLIENT_SECRET }} KAKAO_REDIRECT_URI: ${{ secrets.KAKAO_REDIRECT_URI }} + GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} + GOOGLE_CLIENT_SECRET: ${{ secrets.GOOGLE_CLIENT_SECRET }} + GOOGLE_REDIRECT_URI: ${{ secrets.GOOGLE_REDIRECT_URI }} + steps: - name: Checkout uses: actions/checkout@v4 diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/google/GoogleTokenResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/google/GoogleTokenResponse.java new file mode 100644 index 00000000..fcb83fb3 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/google/GoogleTokenResponse.java @@ -0,0 +1,20 @@ +package com.back.web7_9_codecrete_be.domain.auth.dto.google; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class GoogleTokenResponse { + + @JsonProperty("access_token") + private String accessToken; + + @JsonProperty("expires_in") + private int expiresIn; + + @JsonProperty("token_type") + private String tokenType; + + @JsonProperty("scope") + private String scope; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/google/GoogleUserInfo.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/google/GoogleUserInfo.java new file mode 100644 index 00000000..2b006155 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/google/GoogleUserInfo.java @@ -0,0 +1,13 @@ +package com.back.web7_9_codecrete_be.domain.auth.dto.google; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class GoogleUserInfo { + private String socialId; + private String email; + private String nickname; + private String profileImageUrl; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/google/GoogleUserResponse.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/google/GoogleUserResponse.java new file mode 100644 index 00000000..57010c6e --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/google/GoogleUserResponse.java @@ -0,0 +1,24 @@ +package com.back.web7_9_codecrete_be.domain.auth.dto.google; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; + +@Getter +public class GoogleUserResponse { + + private String id; + private String email; + private String name; + + @JsonProperty("picture") + private String profileImageUrl; + + public GoogleUserInfo toUserInfo() { + return new GoogleUserInfo( + id, + email, + name, + profileImageUrl + ); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/AuthService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/AuthService.java index d8a2fc81..a7800eee 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/AuthService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/AuthService.java @@ -1,5 +1,6 @@ package com.back.web7_9_codecrete_be.domain.auth.service; +import com.back.web7_9_codecrete_be.domain.auth.dto.google.GoogleUserInfo; import com.back.web7_9_codecrete_be.domain.auth.dto.kakao.KakaoUserInfo; import com.back.web7_9_codecrete_be.domain.auth.dto.request.LoginRequest; import com.back.web7_9_codecrete_be.domain.auth.dto.request.SignupRequest; @@ -8,6 +9,7 @@ import com.back.web7_9_codecrete_be.domain.users.entity.SocialType; import com.back.web7_9_codecrete_be.domain.users.entity.User; import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; +import com.back.web7_9_codecrete_be.domain.users.util.NicknameGenerator; import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode; import com.back.web7_9_codecrete_be.global.error.code.UserErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; @@ -28,6 +30,8 @@ public class AuthService { private final EmailService emailService; private final TokenService tokenService; private final KakaoOAuthService kakaoOAuthService; + private final GoogleOAuthService googleOAuthService; + private final NicknameGenerator nicknameGenerator; // 회원가입 public void signUp(SignupRequest req) { @@ -165,10 +169,7 @@ public LoginResponse kakaoLogin(String code) { private User registerKakaoUser(KakaoUserInfo info) { - String nickname = info.getNickname(); - if (userRepository.existsByNickname(nickname)) { - nickname = nickname + "_" + System.currentTimeMillis(); - } + String nickname = nicknameGenerator.generate(); User user = User.builder() .email(info.getEmail()) @@ -183,8 +184,52 @@ private User registerKakaoUser(KakaoUserInfo info) { return userRepository.save(user); } + @Transactional public LoginResponse googleLogin(String code) { - // TODO: 구글 인가 코드 → 사용자 정보 → 로그인 처리 - throw new UnsupportedOperationException("구글 로그인 미구현"); + + // 1. 인가 코드 → 구글 Access Token + String googleAccessToken = googleOAuthService.getAccessToken(code); + + // 2. Access Token → 사용자 정보 + GoogleUserInfo googleUserInfo = googleOAuthService.getUserInfo(googleAccessToken); + + if (googleUserInfo.getEmail() == null) { + throw new BusinessException(AuthErrorCode.SOCIAL_EMAIL_NOT_PROVIDED); + } + + // 3. 소셜 ID 기준 사용자 조회 + User user = userRepository + .findBySocialTypeAndSocialId( + SocialType.GOOGLE, + googleUserInfo.getSocialId() + ) + .orElseGet(() -> registerGoogleUser(googleUserInfo)); + + // 4. 탈퇴 사용자 체크 + if (user.getIsDeleted()) { + throw new BusinessException(UserErrorCode.USER_DELETED); + } + + // 5. 토큰 발급 + tokenService.issueTokens(user); + + return new LoginResponse(user.getId(), user.getNickname()); + } + + private User registerGoogleUser(GoogleUserInfo info) { + + String nickname = nicknameGenerator.generate(); + + User user = User.builder() + .email(info.getEmail()) + .nickname(nickname) + .password(null) + .birth(null) + .profileImage(info.getProfileImageUrl()) + .socialType(SocialType.GOOGLE) + .socialId(info.getSocialId()) + .build(); + + return userRepository.save(user); } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/GoogleOAuthService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/GoogleOAuthService.java new file mode 100644 index 00000000..ab6071ab --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/GoogleOAuthService.java @@ -0,0 +1,74 @@ +package com.back.web7_9_codecrete_be.domain.auth.service; + +import com.back.web7_9_codecrete_be.domain.auth.dto.google.GoogleTokenResponse; +import com.back.web7_9_codecrete_be.domain.auth.dto.google.GoogleUserInfo; +import com.back.web7_9_codecrete_be.domain.auth.dto.google.GoogleUserResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.*; +import org.springframework.stereotype.Service; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; + +@Service +@RequiredArgsConstructor +public class GoogleOAuthService { + + @Value("${oauth.google.client-id}") + private String clientId; + + @Value("${oauth.google.client-secret}") + private String clientSecret; + + @Value("${oauth.google.redirect-uri}") + private String redirectUri; + + private static final String TOKEN_URL = "https://oauth2.googleapis.com/token"; + private static final String USER_INFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"; + + private final RestTemplate restTemplate = new RestTemplate(); + + // 인가 코드 → 액세스 토큰 + public String getAccessToken(String code) { + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "authorization_code"); + params.add("client_id", clientId); + params.add("client_secret", clientSecret); + params.add("redirect_uri", redirectUri); + params.add("code", code); + + HttpEntity> request = + new HttpEntity<>(params, headers); + + ResponseEntity response = + restTemplate.postForEntity( + TOKEN_URL, + request, + GoogleTokenResponse.class + ); + + return response.getBody().getAccessToken(); + } + + // 액세스 토큰 → 구글 사용자 정보 + public GoogleUserInfo getUserInfo(String accessToken) { + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + + HttpEntity request = new HttpEntity<>(headers); + + ResponseEntity response = + restTemplate.exchange( + USER_INFO_URL, + HttpMethod.GET, + request, + GoogleUserResponse.class + ); + + return response.getBody().toUserInfo(); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/util/NicknameGenerator.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/util/NicknameGenerator.java new file mode 100644 index 00000000..be2fdcd8 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/util/NicknameGenerator.java @@ -0,0 +1,41 @@ +package com.back.web7_9_codecrete_be.domain.users.util; + +import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; +import java.util.List; + +@Component +@RequiredArgsConstructor +public class NicknameGenerator { + + private final UserRepository userRepository; + private final SecureRandom random = new SecureRandom(); + + // 접두어 후보들 + private static final List PREFIXES = List.of( + "공연덕후", + "덕질러", + "콘덕", + "라이브덕후", + "공연러" + ); + + public String generate() { + while (true) { + String prefix = PREFIXES.get(random.nextInt(PREFIXES.size())); + String nickname = prefix + "_" + randomNumber6Digits(); + + if (!userRepository.existsByNickname(nickname)) { + return nickname; + } + } + } + + private long randomNumber6Digits() { + // 100000 ~ 999999 + return 100_000L + random.nextInt(900_000); + } +} diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 329002f0..4f3b62e7 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -13,4 +13,8 @@ oauth: kakao: client-id: ${KAKAO_REST_API_KEY} # 카카오 REST API 키 client-secret: ${KAKAO_CLIENT_SECRET} # 카카오 Client Secret - redirect-uri: http://localhost:3000/oauth/kakao # 카카오 로그인 Redirect URI \ No newline at end of file + redirect-uri: http://localhost:3000/oauth/kakao # 카카오 로그인 Redirect URI + google: + client-id: ${GOOGLE_CLIENT_ID} # 구글 클라이언트 아이디 + client-secret: ${GOOGLE_CLIENT_SECRET} # 구글 클라이언트 시크릿 + redirect-uri: http://localhost:3000/oauth/google # 구글 로그인 Redirect URI \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 74a888c1..7ea0b2db 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -54,3 +54,7 @@ oauth: client-id: ${KAKAO_REST_API_KEY} # 카카오 REST API 키 client-secret: ${KAKAO_CLIENT_SECRET} # 카카오 Client Secret redirect-uri: ${KAKAO_REDIRECT_URI} # 카카오 로그인 Redirect URI + google: + client-id: ${GOOGLE_CLIENT_ID} # 구글 클라이언트 아이디 + client-secret: ${GOOGLE_CLIENT_SECRET} # 구글 클라이언트 시크릿 + redirect-uri: ${GOOGLE_REDIRECT_URI} # 구글 로그인 Redirect URI \ No newline at end of file