diff --git a/build.gradle b/build.gradle index cfde4496..a922f0a5 100644 --- a/build.gradle +++ b/build.gradle @@ -36,6 +36,7 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.5' implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // Oauth } tasks.named('test') { diff --git a/src/main/java/hs/kr/backend/devpals/domain/Inquiry/service/InquiryService.java b/src/main/java/hs/kr/backend/devpals/domain/Inquiry/service/InquiryService.java index 3ba1c09d..a7107429 100644 --- a/src/main/java/hs/kr/backend/devpals/domain/Inquiry/service/InquiryService.java +++ b/src/main/java/hs/kr/backend/devpals/domain/Inquiry/service/InquiryService.java @@ -10,7 +10,7 @@ import hs.kr.backend.devpals.global.exception.CustomException; import hs.kr.backend.devpals.global.exception.ErrorException; import hs.kr.backend.devpals.global.jwt.JwtTokenValidator; -import hs.kr.backend.devpals.infra.Aws.AwsS3Client; +import hs.kr.backend.devpals.infra.aws.AwsS3Client; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; diff --git a/src/main/java/hs/kr/backend/devpals/domain/user/facade/UserFacade.java b/src/main/java/hs/kr/backend/devpals/domain/user/facade/UserFacade.java index 99b130ae..d711a7af 100644 --- a/src/main/java/hs/kr/backend/devpals/domain/user/facade/UserFacade.java +++ b/src/main/java/hs/kr/backend/devpals/domain/user/facade/UserFacade.java @@ -11,11 +11,9 @@ import hs.kr.backend.devpals.global.common.ApiResponse; import hs.kr.backend.devpals.global.exception.CustomException; import hs.kr.backend.devpals.global.exception.ErrorException; -import hs.kr.backend.devpals.infra.Aws.AwsS3Client; +import hs.kr.backend.devpals.infra.aws.AwsS3Client; import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; -import org.aspectj.weaver.Position; -import org.aspectj.weaver.patterns.AndPointcut; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; diff --git a/src/main/java/hs/kr/backend/devpals/domain/user/service/UserProfileService.java b/src/main/java/hs/kr/backend/devpals/domain/user/service/UserProfileService.java index 948f77d8..b4f01b90 100644 --- a/src/main/java/hs/kr/backend/devpals/domain/user/service/UserProfileService.java +++ b/src/main/java/hs/kr/backend/devpals/domain/user/service/UserProfileService.java @@ -3,10 +3,8 @@ import hs.kr.backend.devpals.domain.Inquiry.dto.InquiryDto; import hs.kr.backend.devpals.domain.Inquiry.entity.InquiryEntity; import hs.kr.backend.devpals.domain.Inquiry.repository.InquiryRepository; -import hs.kr.backend.devpals.domain.project.dto.CommentDTO; import hs.kr.backend.devpals.domain.project.entity.CommentEntity; import hs.kr.backend.devpals.domain.project.repository.CommentRepoisitory; -import hs.kr.backend.devpals.domain.project.repository.RecommentRepository; import hs.kr.backend.devpals.domain.project.service.ProjectService; import hs.kr.backend.devpals.domain.user.dto.MyCommentResponse; import hs.kr.backend.devpals.domain.user.dto.UserResponse; @@ -18,7 +16,7 @@ import hs.kr.backend.devpals.global.exception.CustomException; import hs.kr.backend.devpals.global.exception.ErrorException; import hs.kr.backend.devpals.global.jwt.JwtTokenValidator; -import hs.kr.backend.devpals.infra.Aws.AwsS3Client; +import hs.kr.backend.devpals.infra.aws.AwsS3Client; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; @@ -26,7 +24,6 @@ import org.springframework.web.multipart.MultipartFile; import java.util.List; -import java.util.stream.Collectors; @Service @RequiredArgsConstructor diff --git a/src/main/java/hs/kr/backend/devpals/global/config/SecurityConfig.java b/src/main/java/hs/kr/backend/devpals/global/config/SecurityConfig.java index a846ccf0..320c1263 100644 --- a/src/main/java/hs/kr/backend/devpals/global/config/SecurityConfig.java +++ b/src/main/java/hs/kr/backend/devpals/global/config/SecurityConfig.java @@ -2,6 +2,9 @@ import hs.kr.backend.devpals.domain.user.principal.CustomUserDetailsService; import hs.kr.backend.devpals.global.jwt.jwtfilter.JwtAuthenticationFilter; +import hs.kr.backend.devpals.infra.oauth2.CustomOauth2UserService; +import hs.kr.backend.devpals.infra.oauth2.Oauth2LoginSuccessHandler; +import lombok.RequiredArgsConstructor; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.web.SecurityFilterChain; import org.springframework.context.annotation.Bean; @@ -24,15 +27,14 @@ @Configuration @EnableWebSecurity @EnableMethodSecurity +@RequiredArgsConstructor public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CustomUserDetailsService userDetailsService; + private final CustomOauth2UserService customOauth2UserService; + private final Oauth2LoginSuccessHandler oauth2LoginSuccessHandler; - public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, CustomUserDetailsService userDetailsService) { - this.jwtAuthenticationFilter = jwtAuthenticationFilter; - this.userDetailsService = userDetailsService; - } @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -44,6 +46,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .formLogin(AbstractHttpConfigurer::disable) // 폼 로그인 비활성화 .logout(AbstractHttpConfigurer::disable) // 로그아웃 비활성화 .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) // 모든 요청을 인증 없이 허용** + .oauth2Login(oauth2 -> oauth2 + .userInfoEndpoint(userInfo -> userInfo + .userService(customOauth2UserService)) // 사용자 정보 처리 + .successHandler(oauth2LoginSuccessHandler) // 로그인 성공 후 처리 + ) .authenticationProvider(authenticationProvider()) // 커스텀 인증 제공자 추가 .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); // JWT 필터 추가 diff --git a/src/main/java/hs/kr/backend/devpals/infra/Aws/AwsS3Client.java b/src/main/java/hs/kr/backend/devpals/infra/Aws/AwsS3Client.java index 526d569c..7c2a8e8e 100644 --- a/src/main/java/hs/kr/backend/devpals/infra/Aws/AwsS3Client.java +++ b/src/main/java/hs/kr/backend/devpals/infra/Aws/AwsS3Client.java @@ -1,4 +1,4 @@ -package hs.kr.backend.devpals.infra.Aws; +package hs.kr.backend.devpals.infra.aws; import hs.kr.backend.devpals.global.exception.CustomException; import hs.kr.backend.devpals.global.exception.ErrorException; diff --git a/src/main/java/hs/kr/backend/devpals/infra/oauth2/CustomOauth2UserService.java b/src/main/java/hs/kr/backend/devpals/infra/oauth2/CustomOauth2UserService.java new file mode 100644 index 00000000..4c9a2726 --- /dev/null +++ b/src/main/java/hs/kr/backend/devpals/infra/oauth2/CustomOauth2UserService.java @@ -0,0 +1,101 @@ +package hs.kr.backend.devpals.infra.oauth2; + +import hs.kr.backend.devpals.domain.user.entity.UserEntity; +import hs.kr.backend.devpals.domain.user.repository.UserRepository; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.user.DefaultOAuth2User; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import java.util.Collections; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class CustomOauth2UserService extends DefaultOAuth2UserService { + + private final UserRepository userRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) { + OAuth2User oAuth2User = super.loadUser(userRequest); + String provider = userRequest.getClientRegistration().getRegistrationId(); + + // request에 provider 저장 + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + if (attributes != null) { + HttpServletRequest request = attributes.getRequest(); + request.setAttribute("provider", provider); + } + + String email = extractEmail(provider, oAuth2User); + String name = extractName(provider, oAuth2User); + + if (email == null) { + throw new IllegalArgumentException("소셜 로그인 응답에서 email을 찾을 수 없습니다."); + } + + UserEntity user = userRepository.findByEmail(email) + .orElseGet(() -> userRepository.save(new UserEntity(email, null, name, true))); + + // attributes에 email이 없는 경우 추가해줌 (DefaultOAuth2User 생성시 필요) + Map attributesMap = new java.util.HashMap<>(oAuth2User.getAttributes()); + attributesMap.put("email", email); + + return new DefaultOAuth2User( + Collections.singleton(new SimpleGrantedAuthority("ROLE_USER")), + attributesMap, + "email" + ); + } + + public static String extractEmail(String provider, OAuth2User oAuth2User) { + switch (provider) { + case "google": + return oAuth2User.getAttribute("email"); + + case "kakao": + Map kakaoAccount = oAuth2User.getAttribute("kakao_account"); + return kakaoAccount != null ? (String) kakaoAccount.get("email") : null; + + case "naver": + Map response = oAuth2User.getAttribute("response"); + return response != null ? (String) response.get("email") : null; + + case "github": + return oAuth2User.getAttribute("email"); + + default: + throw new IllegalArgumentException("Unsupported provider: " + provider); + } + } + + public static String extractName(String provider, OAuth2User oAuth2User) { + switch (provider) { + case "google": + return oAuth2User.getAttribute("name"); + + case "kakao": + Map kakaoAccount = oAuth2User.getAttribute("kakao_account"); + if (kakaoAccount == null) return null; + Map profile = (Map) kakaoAccount.get("profile"); + return profile != null ? (String) profile.get("nickname") : null; + + case "naver": + Map response = oAuth2User.getAttribute("response"); + return response != null ? (String) response.get("name") : null; + + case "github": + return oAuth2User.getAttribute("name"); + + default: + throw new IllegalArgumentException("Unsupported provider: " + provider); + } + } +} diff --git a/src/main/java/hs/kr/backend/devpals/infra/oauth2/Oauth2LoginSuccessHandler.java b/src/main/java/hs/kr/backend/devpals/infra/oauth2/Oauth2LoginSuccessHandler.java new file mode 100644 index 00000000..0a39b688 --- /dev/null +++ b/src/main/java/hs/kr/backend/devpals/infra/oauth2/Oauth2LoginSuccessHandler.java @@ -0,0 +1,76 @@ +package hs.kr.backend.devpals.infra.oauth2; + +import com.fasterxml.jackson.databind.ObjectMapper; +import hs.kr.backend.devpals.domain.auth.dto.LoginResponse; +import hs.kr.backend.devpals.domain.auth.dto.TokenResponse; +import hs.kr.backend.devpals.domain.user.dto.LoginUserResponse; +import hs.kr.backend.devpals.domain.user.entity.UserEntity; +import hs.kr.backend.devpals.domain.user.repository.UserRepository; +import hs.kr.backend.devpals.global.exception.CustomException; +import hs.kr.backend.devpals.global.exception.ErrorException; +import hs.kr.backend.devpals.global.jwt.JwtTokenProvider; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseCookie; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class Oauth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + + private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + + OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal(); + + String provider = (String) request.getAttribute("provider"); + if (provider == null) { + provider = oAuth2User.getAttributes().containsKey("kakao_account") ? "kakao" : + oAuth2User.getAttributes().containsKey("response") ? "naver" : + oAuth2User.getAttributes().containsKey("login") ? "github" : "google"; + } + + String email = CustomOauth2UserService.extractEmail(provider, oAuth2User); + if (email == null) { + throw new CustomException(ErrorException.USER_NOT_FOUND); + } + + UserEntity user = userRepository.findByEmail(email) + .orElseThrow(() -> new CustomException(ErrorException.USER_NOT_FOUND)); + + String accessToken = jwtTokenProvider.generateToken(user.getId()); + String refreshToken = jwtTokenProvider.generateRefreshToken(user.getId()); + + user.updateRefreshToken(refreshToken); + userRepository.save(user); + + ResponseCookie refreshCookie = ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .secure(true) + .path("/") + .maxAge(14 * 24 * 60 * 60) + .build(); + + LoginUserResponse userDto = LoginUserResponse.fromEntity(user); + TokenResponse tokenData = new TokenResponse(accessToken); + LoginResponse result = new LoginResponse<>(true, "소셜 로그인 성공", tokenData, userDto); + + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + response.setHeader("Set-Cookie", refreshCookie.toString()); + + ObjectMapper mapper = new ObjectMapper(); + response.getWriter().write(mapper.writeValueAsString(result)); + } +}