diff --git a/src/main/java/com/bookmile/backend/domain/record/controller/RecordController.java b/src/main/java/com/bookmile/backend/domain/record/controller/RecordController.java index 47cca65..b477aa2 100644 --- a/src/main/java/com/bookmile/backend/domain/record/controller/RecordController.java +++ b/src/main/java/com/bookmile/backend/domain/record/controller/RecordController.java @@ -11,7 +11,6 @@ import com.bookmile.backend.domain.record.service.Impl.RecordServiceImpl; import com.bookmile.backend.global.common.CommonResponse; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import jakarta.validation.Valid; import java.util.List; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/bookmile/backend/domain/user/controller/OAuthController.java b/src/main/java/com/bookmile/backend/domain/user/controller/OAuthController.java new file mode 100644 index 0000000..b0a35d7 --- /dev/null +++ b/src/main/java/com/bookmile/backend/domain/user/controller/OAuthController.java @@ -0,0 +1,57 @@ +package com.bookmile.backend.domain.user.controller; + +import com.bookmile.backend.domain.user.service.OAuthService; +import com.bookmile.backend.global.common.CommonResponse; +import io.swagger.v3.oas.annotations.Operation; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +import static com.bookmile.backend.global.common.StatusCode.SIGN_IN; +import static com.bookmile.backend.global.common.StatusCode.USER_FOUND; + +@RestController +@RequestMapping("api/v1/oauth2") +@RequiredArgsConstructor +public class OAuthController { + private final OAuthService oAuthService; + + @Operation(summary = "[테스트] OAuth2 로그인 (소셜로그인)", description = "테스트용입니다.
" + + "토큰의 유효시간이 매우 짧습니다. accessToken : 30초, refreshToken : 1분
" + + "회원가입 따로 없이, test용 email을 입력하여 계정을 생성하고, redirectUrl이 올바르게 나오는지 확인합니다.
" + + "또한, 제공한 accessToken, refreshToken을 유효한지 확인합니다. ") + @PostMapping("/test") + public ResponseEntity>> testSocialLogin( + @RequestParam String email + ) { + return ResponseEntity.status(SIGN_IN.getStatus()) + .body(CommonResponse.from(SIGN_IN.getMessage(), oAuthService.testSocialLogin(email))); + } + + @Operation(summary = "연동된 소셜 로그인 조회", description = "현재 로그인한 사용자의 연동된 소셜 로그인 목록을 조회합니다.") + @GetMapping + public ResponseEntity>> getOAuthProviders( + @AuthenticationPrincipal UserDetails userDetails) { + + return ResponseEntity.ok(CommonResponse.from(USER_FOUND.getMessage(), oAuthService.getOAuthProviders(userDetails.getUsername()))); + } + + @Operation(summary = "소셜로그인 연동 해제", description = "소셜 로그인 연동을 해제합니다.
" + + "provider에는 kakao, google, naver 중 1개 입력해주세요.
" + + "Header에 accessToken 필요합니다!") + @PostMapping( "unlink/{provider}") + public ResponseEntity> unlinkOAuthProvider( + HttpServletRequest request, + @PathVariable(value = "provider") String provider, + @AuthenticationPrincipal UserDetails userDetails) { + oAuthService.unlinkUserOAuth(request, provider, userDetails.getUsername()); + + return ResponseEntity.ok(CommonResponse.from("연동해제 성공")); + } +} diff --git a/src/main/java/com/bookmile/backend/domain/user/controller/UserController.java b/src/main/java/com/bookmile/backend/domain/user/controller/UserController.java index 172f664..482fc2b 100644 --- a/src/main/java/com/bookmile/backend/domain/user/controller/UserController.java +++ b/src/main/java/com/bookmile/backend/domain/user/controller/UserController.java @@ -11,14 +11,12 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; -import org.springframework.http.RequestEntity; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import java.util.HashMap; import java.util.Map; import static com.bookmile.backend.global.common.StatusCode.*; @@ -135,18 +133,8 @@ public ResponseEntity> testSignIn( .body(CommonResponse.from(SIGN_IN.getMessage(),userService.testSignIn(signInReqDto))); } - @Operation(summary = "[테스트] OAuth2 로그인 (소셜로그인)", description = "테스트용입니다.
" + - "회원가입 따로 없이, test용 email을 입력하여 계정을 생성하고, rediectUrl이 올바르게 나오는지 확인합니다.
" + - "또한, 제공한 accessToken, refreshToken을 유효한지 확인합니다. ") - @PostMapping("/test/social-login") - public ResponseEntity>> testSocialLogin( - @RequestParam String email - ) { - return ResponseEntity.status(SIGN_IN.getStatus()) - .body(CommonResponse.from(SIGN_IN.getMessage(), userService.testSocialLogin(email))); - } - @Operation(summary = "[테스트] 리다이렉트",description = "리다이렉트 url에 토큰이 올바른지 검사합니다.
" + + @Operation(summary = "[테스트] 토큰 확인용",description = "리다이렉트 url에 토큰이 올바른지 검사합니다.
" + "유저의 정보를 확인합니다.") @PostMapping("/test/redirect") public ResponseEntity>> testRedirect( diff --git a/src/main/java/com/bookmile/backend/domain/user/entity/User.java b/src/main/java/com/bookmile/backend/domain/user/entity/User.java index 085fc81..a01fc01 100644 --- a/src/main/java/com/bookmile/backend/domain/user/entity/User.java +++ b/src/main/java/com/bookmile/backend/domain/user/entity/User.java @@ -34,22 +34,14 @@ public class User extends BaseEntity { @Enumerated(EnumType.STRING) private UserRole role; - // OAuth2.0 제공자 - private String provider; - - // OAuth 로그인 유저의 고유 ID - private String providerId; - @Builder - public User(String nickname, String email, String password, String image, Boolean isDeleted, UserRole role, String provider, String providerId) { + public User(String nickname, String email, String password, String image, Boolean isDeleted, UserRole role) { this.nickname = nickname; this.email = email; this.password = password; this.image = image; this.isDeleted = isDeleted; this.role = role; - this.provider = provider; - this.providerId = providerId; } public void updateNickname(String newNickname) { diff --git a/src/main/java/com/bookmile/backend/domain/user/entity/UserOAuth.java b/src/main/java/com/bookmile/backend/domain/user/entity/UserOAuth.java new file mode 100644 index 0000000..89d2451 --- /dev/null +++ b/src/main/java/com/bookmile/backend/domain/user/entity/UserOAuth.java @@ -0,0 +1,38 @@ +package com.bookmile.backend.domain.user.entity; + +import com.bookmile.backend.global.config.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserOAuth extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "oauth_id") + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + // OAuth2.0 제공자 + @Column(nullable = false) + private String provider; + + // OAuth 로그인 유저의 고유 ID + @Column(name= "provider_id", nullable = false, unique = true) + private String providerId; + + @Builder + public UserOAuth(User user, String provider, String providerId) { + this.user = user; + this.provider = provider; + this.providerId = providerId; + } +} diff --git a/src/main/java/com/bookmile/backend/domain/user/repository/UserOAuthRepository.java b/src/main/java/com/bookmile/backend/domain/user/repository/UserOAuthRepository.java new file mode 100644 index 0000000..ea0f9f3 --- /dev/null +++ b/src/main/java/com/bookmile/backend/domain/user/repository/UserOAuthRepository.java @@ -0,0 +1,16 @@ +package com.bookmile.backend.domain.user.repository; + +import com.bookmile.backend.domain.user.entity.User; +import com.bookmile.backend.domain.user.entity.UserOAuth; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface UserOAuthRepository extends JpaRepository { + Optional findByProviderAndProviderId(String provider, String providerId); + List findByUserId(Long userId); + Optional findByUserIdAndProvider(Long userId, String provider); +} \ No newline at end of file diff --git a/src/main/java/com/bookmile/backend/domain/user/repository/UserRepository.java b/src/main/java/com/bookmile/backend/domain/user/repository/UserRepository.java index 17a4ffe..b0500ad 100644 --- a/src/main/java/com/bookmile/backend/domain/user/repository/UserRepository.java +++ b/src/main/java/com/bookmile/backend/domain/user/repository/UserRepository.java @@ -10,8 +10,6 @@ public interface UserRepository extends JpaRepository { Optional findByEmail(String email); - Boolean existsByEmail(String email); - Optional findById(Long userId); Boolean existsByNickname(String nickname); diff --git a/src/main/java/com/bookmile/backend/domain/user/service/OAuthService.java b/src/main/java/com/bookmile/backend/domain/user/service/OAuthService.java new file mode 100644 index 0000000..e6fa4be --- /dev/null +++ b/src/main/java/com/bookmile/backend/domain/user/service/OAuthService.java @@ -0,0 +1,13 @@ +package com.bookmile.backend.domain.user.service; + +import jakarta.servlet.http.HttpServletRequest; + +import java.util.List; +import java.util.Map; + +public interface OAuthService { + List getOAuthProviders(String email); + void unlinkUserOAuth(HttpServletRequest request, String provider, String email); + + Map testSocialLogin(String email); +} diff --git a/src/main/java/com/bookmile/backend/domain/user/service/UserService.java b/src/main/java/com/bookmile/backend/domain/user/service/UserService.java index 52e8669..4bfb43f 100644 --- a/src/main/java/com/bookmile/backend/domain/user/service/UserService.java +++ b/src/main/java/com/bookmile/backend/domain/user/service/UserService.java @@ -6,7 +6,6 @@ import com.bookmile.backend.domain.user.dto.req.UserInfoReqDto; import com.bookmile.backend.domain.user.dto.res.TokenResDto; import com.bookmile.backend.domain.user.dto.res.UserDetailResDto; -import com.bookmile.backend.domain.user.dto.res.UserInfoDto; import com.bookmile.backend.domain.user.dto.res.UserResDto; import jakarta.servlet.http.HttpServletRequest; import org.springframework.web.multipart.MultipartFile; @@ -17,7 +16,6 @@ public interface UserService { UserResDto signUp(SignUpReqDto signUpReqDto); TokenResDto signIn(SignInReqDto signInReqDto); TokenResDto reIssue(HttpServletRequest request); - UserInfoDto getUserInfo(Long userId); UserDetailResDto getUser(String email); Boolean checkNickname(String nickname); @@ -30,6 +28,5 @@ public interface UserService { // 테스트 로그인 TokenResDto testSignIn(SignInReqDto signInReqDto); - Map testSocialLogin(String email); Map testRedirect(String accessToken); } \ No newline at end of file diff --git a/src/main/java/com/bookmile/backend/domain/user/service/impl/OAuthServiceImpl.java b/src/main/java/com/bookmile/backend/domain/user/service/impl/OAuthServiceImpl.java new file mode 100644 index 0000000..dd8bbdd --- /dev/null +++ b/src/main/java/com/bookmile/backend/domain/user/service/impl/OAuthServiceImpl.java @@ -0,0 +1,145 @@ +package com.bookmile.backend.domain.user.service.impl; + +import com.bookmile.backend.domain.user.entity.User; +import com.bookmile.backend.domain.user.entity.UserOAuth; +import com.bookmile.backend.domain.user.repository.UserOAuthRepository; +import com.bookmile.backend.domain.user.repository.UserRepository; +import com.bookmile.backend.domain.user.service.OAuthService; +import com.bookmile.backend.global.common.StatusCode; +import com.bookmile.backend.global.common.UserRole; +import com.bookmile.backend.global.exception.CustomException; +import com.bookmile.backend.global.jwt.JwtTokenProvider; +import com.bookmile.backend.global.oauth.OAuth2UnlinkService; +import com.bookmile.backend.global.common.RandomNickname; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.log4j.Log4j2; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.util.UriComponentsBuilder; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static com.bookmile.backend.global.common.StatusCode.AUTHENTICATION_FAILED; +import static com.bookmile.backend.global.common.StatusCode.INVALID_OAUTH_USER; + +@Service +@RequiredArgsConstructor +@Log4j2 +public class OAuthServiceImpl implements OAuthService { + private final UserRepository userRepository; + private final UserOAuthRepository userOAuthRepository; + private final JwtTokenProvider jwtTokenProvider; + private final RandomNickname randomNickname; + private final OAuth2UnlinkService oAuth2UnlinkService; + + @Value("${aws.main.profile}") + private String mainProfile; + + @Value("${spring.oauth2.url.callback}") + private String callBackUrl; + + // 회원의 소셜 연동 정보 조회 + @Override + @Transactional + public List getOAuthProviders(String email) { + User user = findByEmail(email); + + List providers = userOAuthRepository.findByUserId(user.getId()).stream() + .map(UserOAuth::getProvider) + .toList(); + + return providers; + } + + + @Override + @Transactional + public void unlinkUserOAuth(HttpServletRequest request, String provider, String email) { + String token = jwtTokenProvider.resolveToken(request); + + User user = findByEmail(email); + + UserOAuth userOAuth = userOAuthRepository.findByUserIdAndProvider(user.getId(), provider).orElseThrow( + () -> new CustomException(INVALID_OAUTH_USER)); + + // 연동 해제 + if (provider.equals("kakao")){ + oAuth2UnlinkService.unlinkKakao(userOAuth.getProviderId()); + }else if(provider.equals("naver")){ + oAuth2UnlinkService.unlinkNaver(token); + }else if(provider.equals("google")){ + oAuth2UnlinkService.unlinkGoogle(token); + }else{ + throw new CustomException(StatusCode.INPUT_VALUE_INVALID); + } + + userOAuthRepository.delete(userOAuth); + } + + // [테스트용] - OAuth 로그인 + @Override + public Map testSocialLogin(String email) { + + // test용 유저 생성 + User testUser = userRepository.findByEmail(email).orElseGet(() -> { + + // 유저 정보 저장 + User newUser = userRepository.save( User.builder() + .email(email) + .nickname(randomNickname.generateNickname()) + .image(mainProfile) + .role(UserRole.USER) + .isDeleted(false) + .build()); + + // OAuth2.0 정보 저장 + userOAuthRepository.save( UserOAuth.builder() + .user(newUser) + .provider("test") + .providerId("test") + .build()); + return newUser; + }); + + // OAuth2User 생성 + Map attributes = new HashMap<>(); + attributes.put("email", testUser.getEmail()); + attributes.put("exist", true); + attributes.put("userId", testUser.getId()); + + OAuth2User oAuth2User = new DefaultOAuth2User( + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), + attributes, "email"); + + String accessToken = jwtTokenProvider.createTestAccessToken(testUser.getEmail(), testUser.getId(), + testUser.getRole().toString()); + String refreshToken = jwtTokenProvider.createTestRefreshToken(testUser.getEmail(), testUser.getId()); + + String redirectUrl = UriComponentsBuilder.fromHttpUrl(callBackUrl) + .queryParam("testAccess", accessToken) + .queryParam("testRefresh", refreshToken) + .toUriString(); + + log.info("UserServiceImpl.testSocialLogin: redirectUrl - {},", redirectUrl); + + Map response = new HashMap<>(); + response.put("redirectUrl", redirectUrl); + response.put("accessToken", accessToken); + response.put("refreshToken", refreshToken); + + return response; + } + + private User findByEmail(String email) { + return userRepository.findByEmail(email).orElseThrow(() -> new CustomException(AUTHENTICATION_FAILED)); + } + +} diff --git a/src/main/java/com/bookmile/backend/domain/user/service/impl/UserServiceImpl.java b/src/main/java/com/bookmile/backend/domain/user/service/impl/UserServiceImpl.java index 9c6f563..3f8f016 100644 --- a/src/main/java/com/bookmile/backend/domain/user/service/impl/UserServiceImpl.java +++ b/src/main/java/com/bookmile/backend/domain/user/service/impl/UserServiceImpl.java @@ -1,42 +1,22 @@ package com.bookmile.backend.domain.user.service.impl; -import static com.bookmile.backend.global.common.StatusCode.AUTHENTICATION_FAILED; -import static com.bookmile.backend.global.common.StatusCode.EMAIL_CODE_NOT_MATCH; -import static com.bookmile.backend.global.common.StatusCode.EMAIL_TOO_MANY_REQUESTS; -import static com.bookmile.backend.global.common.StatusCode.INVALID_FILE_TYPE; -import static com.bookmile.backend.global.common.StatusCode.INVALID_TOKEN; -import static com.bookmile.backend.global.common.StatusCode.MAIL_SERVER_ERROR; -import static com.bookmile.backend.global.common.StatusCode.PASSWORD_DUPLICATE; -import static com.bookmile.backend.global.common.StatusCode.PASSWORD_NOT_MATCH; -import static com.bookmile.backend.global.common.StatusCode.TOKEN_NOT_FOUND; -import static com.bookmile.backend.global.common.StatusCode.USER_ALREADY_EXISTS; -import static com.bookmile.backend.global.common.StatusCode.USER_NOT_FOUND; +import static com.bookmile.backend.global.common.StatusCode.*; import com.bookmile.backend.domain.image.service.ImageService; -import com.bookmile.backend.domain.user.dto.req.PasswordReqDto; -import com.bookmile.backend.domain.user.dto.req.SignInReqDto; -import com.bookmile.backend.domain.user.dto.req.SignUpReqDto; -import com.bookmile.backend.domain.user.dto.req.UserInfoReqDto; -import com.bookmile.backend.domain.user.dto.res.TokenResDto; -import com.bookmile.backend.domain.user.dto.res.UserDetailResDto; -import com.bookmile.backend.domain.user.dto.res.UserInfoDto; -import com.bookmile.backend.domain.user.dto.res.UserResDto; +import com.bookmile.backend.domain.user.dto.req.*; +import com.bookmile.backend.domain.user.dto.res.*; import com.bookmile.backend.domain.user.entity.User; -import com.bookmile.backend.domain.user.repository.UserRepository; +import com.bookmile.backend.domain.user.repository.*; import com.bookmile.backend.domain.user.service.UserService; -import com.bookmile.backend.global.common.UserRole; import com.bookmile.backend.global.exception.CustomException; import com.bookmile.backend.global.jwt.JwtTokenProvider; -import com.bookmile.backend.global.oauth.nickname.RandomNickname; -import com.bookmile.backend.global.redis.RefreshToken; -import com.bookmile.backend.global.redis.RefreshTokenRepository; +import com.bookmile.backend.global.common.RandomNickname; +import com.bookmile.backend.global.redis.*; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import jakarta.servlet.http.HttpServletRequest; -import java.util.Collections; -import java.util.HashMap; -import java.util.Map; -import java.util.Random; + +import java.util.*; import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -45,14 +25,10 @@ import org.springframework.data.redis.core.StringRedisTemplate; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.scheduling.annotation.Async; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.security.oauth2.core.user.DefaultOAuth2User; -import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import org.springframework.web.util.UriComponentsBuilder; @Service @RequiredArgsConstructor @@ -76,9 +52,6 @@ public class UserServiceImpl implements UserService { @Value("${aws.main.profile}") private String mainProfile; - @Value("${spring.oauth2.url.callback}") - private String callBackUrl; - private static final String[] ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg"}; @Override @@ -157,15 +130,6 @@ public TokenResDto reIssue(HttpServletRequest request) { return TokenResDto.toDto(newAccessToken, newRefreshToken); } - // 회원 정보 조회 - @Override - public UserInfoDto getUserInfo(Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new CustomException(USER_NOT_FOUND)); - - return UserInfoDto.toDto(user); - } - // 회원 정보 조회 (토큰) @Override public UserDetailResDto getUser(String email) { @@ -298,7 +262,7 @@ public void deleteUser(String email) { user.updateIsDeleted(); } - // 테스트용 - 로그인 + // [테스트용] - 로그인 @Override @Transactional public TokenResDto testSignIn(SignInReqDto signInReqDto) { @@ -315,54 +279,7 @@ public TokenResDto testSignIn(SignInReqDto signInReqDto) { return TokenResDto.toDto(accessToken, refreshToken); } - // 테스트용 - OAuth 로그인 - @Override - @Transactional - public Map testSocialLogin(String email) { - - // test용 유저 생성 - User testUser = userRepository.findByEmail(email).orElseGet(() -> { - User newUser = User.builder() - .email(email) - .nickname(randomNickname.generateNickname()) - .image(mainProfile) - .provider("test") - .providerId("test") - .role(UserRole.USER) - .isDeleted(false) - .build(); - return userRepository.save(newUser); - }); - - // OAuth2User 생성 - Map attributes = new HashMap<>(); - attributes.put("email", testUser.getEmail()); - attributes.put("exist", true); - attributes.put("userId", testUser.getId()); - - OAuth2User oAuth2User = new DefaultOAuth2User( - Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), - attributes, "email"); - - String accessToken = jwtTokenProvider.createTestAccessToken(testUser.getEmail(), testUser.getId(), - testUser.getRole().toString()); - String refreshToken = jwtTokenProvider.createTestRefreshToken(testUser.getEmail(), testUser.getId()); - - String redirectUrl = UriComponentsBuilder.fromHttpUrl(callBackUrl) - .queryParam("testAccess", accessToken) - .queryParam("testRefresh", refreshToken) - .toUriString(); - - log.info("UserServiceImpl.testSocialLogin: redirectUrl - {},", redirectUrl); - - Map response = new HashMap<>(); - response.put("redirectUrl", redirectUrl); - response.put("accessToken", accessToken); - response.put("refreshToken", refreshToken); - - return response; - } - + // [테스트용] 리다이렉트 경로 확인 @Override public Map testRedirect(String accessToken) { log.info("UserServiceImpl.testRedirect: accessToken - {} ", accessToken); @@ -378,7 +295,6 @@ public Map testRedirect(String accessToken) { return response; } - private boolean validateImageFile(MultipartFile file) { String extension = FilenameUtils.getExtension(file.getOriginalFilename()).toLowerCase(); @@ -390,6 +306,10 @@ private boolean validateImageFile(MultipartFile file) { return false; } + private User findByEmail(String email) { + return userRepository.findByEmail(email).orElseThrow(() -> new CustomException(AUTHENTICATION_FAILED)); + } + private Map getUserIdByToken(HttpServletRequest request) { Map map = new HashMap<>(); @@ -410,12 +330,8 @@ private void existsByEmail(String email) { if (userRepository.existsByEmailAndIsDeletedFalse(email)) { throw new CustomException(USER_ALREADY_EXISTS); } - ; } - private User findByEmail(String email) { - return userRepository.findByEmail(email).orElseThrow(() -> new CustomException(AUTHENTICATION_FAILED)); - } // 이메일 요청 카운트 증가 private void increaseEmailRequestCount(String email) { diff --git a/src/main/java/com/bookmile/backend/global/oauth/nickname/RandomNickname.java b/src/main/java/com/bookmile/backend/global/common/RandomNickname.java similarity index 98% rename from src/main/java/com/bookmile/backend/global/oauth/nickname/RandomNickname.java rename to src/main/java/com/bookmile/backend/global/common/RandomNickname.java index 5a2969e..a831630 100644 --- a/src/main/java/com/bookmile/backend/global/oauth/nickname/RandomNickname.java +++ b/src/main/java/com/bookmile/backend/global/common/RandomNickname.java @@ -1,4 +1,4 @@ -package com.bookmile.backend.global.oauth.nickname; +package com.bookmile.backend.global.common; import com.bookmile.backend.domain.user.repository.UserRepository; import com.bookmile.backend.global.exception.CustomException; diff --git a/src/main/java/com/bookmile/backend/global/common/StatusCode.java b/src/main/java/com/bookmile/backend/global/common/StatusCode.java index 424c73e..22f5ac4 100644 --- a/src/main/java/com/bookmile/backend/global/common/StatusCode.java +++ b/src/main/java/com/bookmile/backend/global/common/StatusCode.java @@ -65,7 +65,6 @@ public enum StatusCode { FORBIDDEN_TOKEN(FORBIDDEN, "접근 권한이 없습니다."), TOKEN_NOT_FOUND(NOT_FOUND, "존재하는 토큰이 없습니다."), - /* 400 BAD_REQUEST : 잘못된 요청 */ PASSWORD_NOT_MATCH(BAD_REQUEST, "비밀번호가 일치하지 않습니다."), PASSWORD_DUPLICATE(BAD_REQUEST, "이전 비밀번호와 동일합니다."), @@ -76,6 +75,10 @@ public enum StatusCode { CUSTOM_GOAL_REQUIRED(BAD_REQUEST, "GoalType이 CUSTOM일 경우 사용자 정의 목표(customGoal)는 필수입니다."), MULTI_PART_FILE_INVALID(BAD_REQUEST,"유효하지 않은 MultiPartFile입니다."), FILE_INVALID(BAD_REQUEST,"파일 저장 중 오류가 발생했습니다."), + INVALID_REQUEST(BAD_REQUEST,"유효하지 않은 요청입니다."), + INVALID_GROUP_STATUS_UPDATE(BAD_REQUEST,"잘못된 요청입니다."), + INVALID_GROUP(BAD_REQUEST,"유효하지 않은 그룹입니다." ), + INVALID_BOOK_ID(BAD_REQUEST,"존재하지 않는 책입니다." ), /* 401 UNAUTHORIZED : 비인증 사용자 */ AUTHENTICATION_FAILED(UNAUTHORIZED, "회원의 정보가 일치하지 않습니다."), @@ -98,6 +101,7 @@ public enum StatusCode { INVALID_GOAL_TYPE(NOT_FOUND, "유효하지 않은 GoalType 값입니다"), INVALID_TEMPLATE_ID(NOT_FOUND, "존재하지 않는 템플릿입니다."), BOOK_INFO_NOT_FOUND(NOT_FOUND, "책 정보를 가져올 수 없습니다."), + INVALID_OAUTH_USER(NOT_FOUND, "해당 소셜 계정이 연동되지 않았습니다."), /* 409 CONFLICT : 리소스 충돌 */ USER_ALREADY_EXISTS(CONFLICT, "이미 존재하는 회원입니다."), @@ -107,15 +111,13 @@ public enum StatusCode { NICKNAME_TOO_MANY_REQUESTS(TOO_MANY_REQUESTS, "더이상 생성할 닉네임이 없습니다."), /* 500 INTERNAL_SERVER_ERROR Error */ + SERVER_ERROR(INTERNAL_SERVER_ERROR, "프로그램 서버에 연결할 수 없습니다."), REDIS_ERROR(INTERNAL_SERVER_ERROR, "Redis 서버에 연결할 수 없습니다. "), MAIL_SERVER_ERROR(INTERNAL_SERVER_ERROR, "메일 서버에 오류가 생겼습니다."), INVALID_TEMPLATE_USAGE(NOT_FOUND,"완독한 그룹의 템플릿만 사용할 수 있습니다."), GOAL_CONTENT_REQUIRED(NOT_FOUND,"목표 상세 내용은 필수입니다." ), NO_PERMISSION(UNAUTHORIZED,"그룹장만 그룹의 상태를 변경할 수 있습니다."), NOT_MEMBER(UNAUTHORIZED,"그룹 구성원이 아닙니다." ), - INVALID_GROUP_STATUS_UPDATE(BAD_REQUEST,"잘못된 요청입니다."), - INVALID_GROUP(BAD_REQUEST,"유효하지 않은 그룹입니다." ), - INVALID_BOOK_ID(BAD_REQUEST,"존재하지 않는 책입니다." ), BESTSELLER_SEARCH(OK,"베스트 셀러 조회에 성공했습니다." ), NEWBOOK_SEARCH(OK,"신간 도서 조회에 성공했습니다." ), diff --git a/src/main/java/com/bookmile/backend/global/oauth/CustomOAuth2UserService.java b/src/main/java/com/bookmile/backend/global/oauth/CustomOAuth2UserService.java index abb6c66..e2d12da 100644 --- a/src/main/java/com/bookmile/backend/global/oauth/CustomOAuth2UserService.java +++ b/src/main/java/com/bookmile/backend/global/oauth/CustomOAuth2UserService.java @@ -1,8 +1,10 @@ package com.bookmile.backend.global.oauth; import com.bookmile.backend.domain.user.entity.User; +import com.bookmile.backend.domain.user.entity.UserOAuth; +import com.bookmile.backend.domain.user.repository.UserOAuthRepository; import com.bookmile.backend.domain.user.repository.UserRepository; -import com.bookmile.backend.global.oauth.nickname.RandomNickname; +import com.bookmile.backend.global.common.RandomNickname; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -24,6 +26,7 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final UserRepository userRepository; + private final UserOAuthRepository userOAuthRepository; private final RandomNickname randomNickname; @Override @@ -33,40 +36,53 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic OAuth2User oauth2User = super.loadUser(userRequest); // 클라이언트 등록 ID(google, naver, kakao)와 사용자 이름 속성을 가져온다. - String registrationId = userRequest.getClientRegistration().getRegistrationId(); - - String userNameAttributeName = userRequest.getClientRegistration() - .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); - log.info("CustomOAuth2UserService.loadUser: userNameAttributeName {} " , userNameAttributeName); + String provider= userRequest.getClientRegistration().getRegistrationId(); // OAuth2UserService를 사용하여 가져온 OAuth2User 정보로 OAuth2UserInfo 객체를 만든다. - OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfo.of(registrationId, userNameAttributeName, oauth2User.getAttributes()); - log.info("CustomOAuth2UserService.loadUser: OAuth2UserInfo - {}", oAuth2UserInfo); + OAuth2UserInfo oAuth2UserInfo = OAuth2UserInfo.of(provider, oauth2User.getAttributes()); + //log.info("CustomOAuth2UserService.loadUser: OAuth2UserInfo - {}", oAuth2UserInfo); Map userAttributes = oAuth2UserInfo.convertToMap(); + log.info("CustomOAuth2UserService.loadUser: userAttributes {}", userAttributes); String email = (String) userAttributes.get("email"); + String providerId = (String) userAttributes.get("attributeKey"); + log.info("CustomOAuth2UserService.loadUser: providerId {}", providerId); // User 정보 반환 Optional findUser = userRepository.findByEmail(email); + boolean isFirstLogin = findUser.isEmpty(); - if(findUser.isEmpty()) { - + User user = findUser.orElseGet(() -> { + // 새로운 유저 추가 User newUser = userRepository.save(oAuth2UserInfo.toEntity(randomNickname)); - // 존재하지 않을 경우 - userAttributes.put("exist", false); - userAttributes.put("userId", newUser.getId()); - return new DefaultOAuth2User( - Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), - userAttributes, "email"); + // 소셜 정보 저장 + userOAuthRepository.save(UserOAuth.builder() + .user(newUser) + .provider(provider) + .providerId(providerId) + .build()); + + return newUser; + }); + + Optional existingOAuth = userOAuthRepository.findByProviderAndProviderId(provider, providerId); + + // 기존 유저 + 새로운 provider 추가 + if (!isFirstLogin && existingOAuth.isEmpty()) { + userOAuthRepository.save( + UserOAuth.builder() + .user(user) + .provider(provider) + .providerId(providerId) + .build()); } - // 존재하는 경우 - userAttributes.put("exist", true); - userAttributes.put("userId", findUser.map(User::getId).orElse(null)); + userAttributes.put("exist", !isFirstLogin); // 최초 로그인 여부에 따라 T/F + userAttributes.put("userId", user.getId()); return new DefaultOAuth2User( - Collections.singleton(new SimpleGrantedAuthority(findUser.get().getRole().toString())), + Collections.singleton(new SimpleGrantedAuthority(user.getRole().toString())), userAttributes, "email"); } diff --git a/src/main/java/com/bookmile/backend/global/oauth/OAuth2SuccessHandler.java b/src/main/java/com/bookmile/backend/global/oauth/OAuth2SuccessHandler.java index e4e3389..3f83aa5 100644 --- a/src/main/java/com/bookmile/backend/global/oauth/OAuth2SuccessHandler.java +++ b/src/main/java/com/bookmile/backend/global/oauth/OAuth2SuccessHandler.java @@ -26,7 +26,7 @@ public class OAuth2SuccessHandler implements AuthenticationSuccessHandler { private String callbackUrl; @Value("${spring.oauth2.url.sign-up}") - private String signUpUrl; + private String mainUrl; @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { @@ -58,8 +58,8 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo response.sendRedirect(redirectUrl); } else{ - // 회원 정보 새로 저장 -> 로그인 페이지로 이동 - String redirectUrl = UriComponentsBuilder.fromHttpUrl(signUpUrl) + // 회원 정보 새로 저장 -> 메인페이지로 이동 + String redirectUrl = UriComponentsBuilder.fromHttpUrl(mainUrl) .toUriString(); response.sendRedirect(redirectUrl); } diff --git a/src/main/java/com/bookmile/backend/global/oauth/OAuth2UnlinkService.java b/src/main/java/com/bookmile/backend/global/oauth/OAuth2UnlinkService.java new file mode 100644 index 0000000..16fdc43 --- /dev/null +++ b/src/main/java/com/bookmile/backend/global/oauth/OAuth2UnlinkService.java @@ -0,0 +1,128 @@ +package com.bookmile.backend.global.oauth; + +import com.bookmile.backend.global.common.StatusCode; +import com.bookmile.backend.global.exception.CustomException; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.util.Map; + +@Slf4j +@Service +@RequiredArgsConstructor +public class OAuth2UnlinkService { + + private String GOOGLE_URL = "https://oauth2.googleapis.com/revoke"; + private String KAKAO_URL = "https://kapi.kakao.com/v1/user/unlink"; + private String NAVER_URL = "https://nid.naver.com/oauth2.0/token"; + + + @Value("${spring.security.oauth2.client.registration.naver.client-id}") + private String NAVER_CLIENT_ID; + + @Value("${spring.security.oauth2.client.registration.naver.client-secret}") + private String NAVER_CLIENT_SECRET; + + @Value("${spring.security.oauth2.client.provider.kakao.admin-key}") + private String KAKAO_ADMIN_KEY; + + private final RestTemplate restTemplate; + + public void unlinkKakao(String providerId) { + + // 헤더에 admin key 넣기 + HttpHeaders headers = new HttpHeaders(); + headers.set("Authorization", "KakaoAK " + KAKAO_ADMIN_KEY); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("target_id_type", "user_id"); + params.add("target_id", providerId); + log.info("unlinkKaKao : {}", params); + + HttpEntity> requestEntity = new HttpEntity<>(params, headers); + + try { + // 카카오 API 로 POST 호출 + ResponseEntity response = restTemplate.exchange(KAKAO_URL, HttpMethod.POST, requestEntity, Map.class); + + if (response.getStatusCode() == HttpStatus.OK) { + log.info("카카오 연동 해제 성공"); + } else { + log.error(" 카카오 연동 해제 실패: {}", response.getBody()); + } + } catch(HttpClientErrorException e) { + if(e.getStatusCode() == HttpStatus.UNAUTHORIZED) { + log.error("KaKao 토큰 만료"); + throw new CustomException(StatusCode.INVALID_TOKEN); + } + } + } + + public void unlinkNaver(String accessToken) { + + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("grant_type", "delete"); + params.add("client_id", NAVER_CLIENT_ID); + params.add("client_secret", NAVER_CLIENT_SECRET); + params.add("access_token", accessToken); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity> requestEntity = new HttpEntity<>(params, headers); + + // 연동 해제 API 생성 => POST + ResponseEntity response = restTemplate.exchange(NAVER_URL, HttpMethod.POST, requestEntity, UnlinkResponse.class); + + if(response.getBody() == null && !"success".equalsIgnoreCase(response.getBody().getResult())){ + throw new CustomException(StatusCode.SERVER_ERROR); + } + + log.info("OAuth2UnlinkService.unlinkNaver: {}", response.getBody().getResult()); + } + + public void unlinkGoogle(String accessToken) { + MultiValueMap params = new LinkedMultiValueMap<>(); + params.add("token", accessToken); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + HttpEntity> requestEntity = new HttpEntity<>(params, headers); + + try { + // 구글 API 로 POST 호출 + ResponseEntity response = restTemplate.exchange(GOOGLE_URL, HttpMethod.POST,requestEntity, Map.class); + + if (response.getStatusCode() == HttpStatus.OK) { + log.info("구글 연동 해제 성공"); + } else { + log.error("구글 연동 해제 실패: {}", response.getBody()); + } + } catch(HttpClientErrorException e) { + if(e.getStatusCode() == HttpStatus.UNAUTHORIZED) { + log.error("토큰 만료"); + throw new CustomException(StatusCode.INVALID_TOKEN); + } + } + } + + // 네이버 응답 데이터 + @Getter + @RequiredArgsConstructor + public static class UnlinkResponse{ + @JsonProperty("access_token") + private String accessToken; + private String result; + } +} diff --git a/src/main/java/com/bookmile/backend/global/oauth/OAuth2UserInfo.java b/src/main/java/com/bookmile/backend/global/oauth/OAuth2UserInfo.java index 1952a8e..16762e1 100644 --- a/src/main/java/com/bookmile/backend/global/oauth/OAuth2UserInfo.java +++ b/src/main/java/com/bookmile/backend/global/oauth/OAuth2UserInfo.java @@ -3,7 +3,7 @@ import com.bookmile.backend.domain.user.entity.User; import com.bookmile.backend.global.common.UserRole; import com.bookmile.backend.global.exception.CustomException; -import com.bookmile.backend.global.oauth.nickname.RandomNickname; +import com.bookmile.backend.global.common.RandomNickname; import lombok.Builder; import lombok.Getter; @@ -32,45 +32,45 @@ private OAuth2UserInfo(Map attributes, String attributeKey, Stri this.profile = profile; } - public static OAuth2UserInfo of(String provider, String attributeKey, Map attributes){ + public static OAuth2UserInfo of(String provider,Map attributes){ return switch (provider) { - case "google" -> ofGoogle(attributeKey, attributes); - case "kakao" -> ofKakao(attributeKey, attributes); - case "naver" -> ofNaver(attributeKey, attributes); + case "google" -> ofGoogle( attributes); + case "kakao" -> ofKakao(attributes); + case "naver" -> ofNaver(attributes); default -> throw new CustomException(PROVIDER_NOT_FOUND); }; } - private static OAuth2UserInfo ofGoogle(String attributeKey, Map attributes) { + private static OAuth2UserInfo ofGoogle(Map attributes) { return OAuth2UserInfo.builder() .provider("google") .attributes(attributes) - .attributeKey(attributeKey) + .attributeKey(String.valueOf(attributes.get("sub"))) .email(String.valueOf(attributes.get("email"))) .profile((String) attributes.get("picture")) .build(); } - private static OAuth2UserInfo ofKakao(String attributeKey, Map attributes) { + private static OAuth2UserInfo ofKakao(Map attributes) { Map account = (Map) attributes.get("kakao_account"); Map profile = (Map) account.get("profile"); return OAuth2UserInfo.builder() .provider("kakao") .attributes(attributes) - .attributeKey(attributeKey) + .attributeKey(String.valueOf(attributes.get("id"))) .email(String.valueOf(account.get("email"))) .profile(String.valueOf(profile.get("profile_image_url"))) .build(); } - private static OAuth2UserInfo ofNaver(String attributeKey, Map attributes) { + private static OAuth2UserInfo ofNaver(Map attributes) { Map response = (Map) attributes.get("response"); return OAuth2UserInfo.builder() .provider("naver") .attributes(attributes) - .attributeKey(attributeKey) + .attributeKey(String.valueOf(response.get("id"))) .email(String.valueOf(response.get("email"))) .profile(String.valueOf(response.get("profile_image"))) .build(); @@ -83,8 +83,6 @@ public User toEntity(RandomNickname nickname) { .nickname(nickname.generateNickname()) .email(email) .image(profile) - .provider(provider) - .providerId(attributeKey) .role(UserRole.USER) .isDeleted(false) .build(); diff --git a/src/test/java/com/bookmile/backend/domain/user/service/UserServiceImplTest.java b/src/test/java/com/bookmile/backend/domain/user/service/UserServiceImplTest.java deleted file mode 100644 index 1c3cd10..0000000 --- a/src/test/java/com/bookmile/backend/domain/user/service/UserServiceImplTest.java +++ /dev/null @@ -1,37 +0,0 @@ -package com.bookmile.backend.domain.user.service; - -import com.bookmile.backend.domain.user.dto.res.UserInfoDto; -import com.bookmile.backend.domain.user.entity.User; -import com.bookmile.backend.domain.user.repository.UserRepository; -import com.bookmile.backend.domain.user.service.impl.UserServiceImpl; -import jakarta.transaction.Transactional; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.boot.test.context.SpringBootTest; - -@SpringBootTest -@Transactional -public class UserServiceImplTest { - - @Autowired - private UserRepository userRepository; - - @Autowired - private UserServiceImpl userServiceImpl; - - @Test - void 사용자_정보_조회() { - User user = new User(null, "kjy154969@gmail.com", "1234", null); - - userRepository.save(user); - - UserInfoDto userInfo = userServiceImpl.getUserInfo(user.getId()); - - Assertions.assertEquals(null, userInfo.getNickName()); - Assertions.assertEquals("kjy154969@gmail.com", userInfo.getEmail()); - Assertions.assertEquals(null, userInfo.getImage()); - - } - -}