diff --git a/build.gradle b/build.gradle index e7ccc4d..56310d5 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,9 @@ dependencies { // modelmapper implementation 'org.modelmapper:modelmapper:2.4.4' + + // OAuth2 + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } diff --git a/src/main/java/com/chukapoka/server/common/authority/AppConfig.java b/src/main/java/com/chukapoka/server/common/authority/AppConfig.java index 46f442c..2fd079c 100644 --- a/src/main/java/com/chukapoka/server/common/authority/AppConfig.java +++ b/src/main/java/com/chukapoka/server/common/authority/AppConfig.java @@ -6,10 +6,14 @@ import org.modelmapper.convention.MatchingStrategies; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; @Configuration @RequiredArgsConstructor public class AppConfig { + + /** ModelMapper 사용하기위한 Bean 등록 */ @Bean public ModelMapper modelMapper() { ModelMapper modelMapper = new ModelMapper(); @@ -19,4 +23,10 @@ public ModelMapper modelMapper() { .setFieldAccessLevel(org.modelmapper.config.Configuration.AccessLevel.PRIVATE); return modelMapper; } + + /** 비밀번호 암호화를 위해 BCryptPasswordEncoder 등록 */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } } diff --git a/src/main/java/com/chukapoka/server/common/authority/SecurityConfig.java b/src/main/java/com/chukapoka/server/common/authority/SecurityConfig.java index 509fbac..64b8545 100644 --- a/src/main/java/com/chukapoka/server/common/authority/SecurityConfig.java +++ b/src/main/java/com/chukapoka/server/common/authority/SecurityConfig.java @@ -3,6 +3,9 @@ import com.chukapoka.server.common.authority.jwt.JwtAuthenticationFilter; import com.chukapoka.server.common.authority.jwt.JwtTokenProvider; +import com.chukapoka.server.common.authority.oauth2.handler.CustomAuthenticationFailHandler; +import com.chukapoka.server.common.authority.oauth2.handler.CustomAuthenticationSuccessHandler; +import com.chukapoka.server.common.authority.oauth2.service.CustomOAuth2UserService; import com.chukapoka.server.common.enums.Authority; import com.chukapoka.server.common.repository.TokenRepository; import lombok.RequiredArgsConstructor; @@ -14,8 +17,6 @@ import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -28,6 +29,10 @@ public class SecurityConfig { */ private final JwtTokenProvider jwtTokenProvider; private final TokenRepository tokenRepository; + private final CustomOAuth2UserService customOAuth2UserService; + private final CustomAuthenticationSuccessHandler oAuth2LoginSuccessHandler; + private final CustomAuthenticationFailHandler oAuthenticationFailHandler; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -49,12 +54,19 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/user/logout", "api/user/reissue", "api/tree/**","api/treeItem/**").hasRole(Authority.USER.getAuthority()// hasAnyRole은 "ROLE_" 접두사를 자동으로 추가해줌 하지만 Authority는 "ROLE_USER"로 설정해야했음 이것떄문에 회원가입할떄 권한이 안넘어갔음 ); }); + + /** OAuth2 로그인 설정 */ + http + .oauth2Login((oauth2) -> + oauth2 + .userInfoEndpoint(userInfoEndpointConfig -> + userInfoEndpointConfig + .userService(customOAuth2UserService)) // OAuth2 로그인시 사용자 정보를 가져오는 엔드포인트와 사용자 서비스를 설정 + .failureHandler(oAuthenticationFailHandler) // OAuth2 로그인 실패시 처리할 핸들러를 지정 + .successHandler(oAuth2LoginSuccessHandler) // OAuth2 로그인 성공시 처리할 핸들러를 지정 + ); + return http.build(); } - // 비밀번호 암호화를 위해 BCryptPasswordEncoder 등록 - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } } diff --git a/src/main/java/com/chukapoka/server/common/authority/oauth2/dto/OAuth2Attribute.java b/src/main/java/com/chukapoka/server/common/authority/oauth2/dto/OAuth2Attribute.java new file mode 100644 index 0000000..ab3ce6e --- /dev/null +++ b/src/main/java/com/chukapoka/server/common/authority/oauth2/dto/OAuth2Attribute.java @@ -0,0 +1,89 @@ +package com.chukapoka.server.common.authority.oauth2.dto; + +import com.chukapoka.server.common.enums.EmailType; +import lombok.*; + +import java.util.HashMap; +import java.util.Map; + +@ToString +@Builder(access = AccessLevel.PRIVATE) // Builder 메서드를 외부에서 사용하지 않으므로, Private 제어자로 지정 +@Getter +public class OAuth2Attribute { + private Map attributes; // 사용자 속성 정보를 담는 Map + private String attributeId; // 사용자 속성의 키 값 + private String emailType; //GOOGLE , NAVER + private String email; // 이메일 정보 + private String name; //사용자 정보 + + + + public static OAuth2Attribute of(String emailType, String userNameAttributeName, Map attributes) { + switch (emailType) { + case "google": + return ofGoogle(userNameAttributeName, attributes); + case "kakao": + return ofKakao( userNameAttributeName, attributes); + case "naver": + return ofNaver(emailType, userNameAttributeName, attributes); + default: + throw new RuntimeException(); + } + } + + /** + * Google 로그인일 경우 사용하는 메서드, 사용자 정보가 따로 Wrapping 되지 않고 제공되어, + * 바로 get() 메서드로 접근이 가능하다. + * */ + private static OAuth2Attribute ofGoogle(String userNameAttributeName, + Map attributes ) { + return OAuth2Attribute.builder() + .email(attributes.get("email").toString()) + .emailType(EmailType.GOOGLE.name()) + .attributes(attributes) + .attributeId(attributes.get(userNameAttributeName).toString()) + .name( attributes.get("name").toString()) + .build(); + } + /** + * Kakao 로그인일 경우 사용하는 메서드, 필요한 사용자 정보가 kakaoAccount -> kakaoProfile 두번 감싸져 있어서, + * 두번 get() 메서드를 이용해 사용자 정보를 담고있는 Map을 꺼내야한다. + * */ + private static OAuth2Attribute ofKakao( String userNameAttributeName,Map attributes) { + + Map kakaoAccount = (Map) attributes.get("kakao_account"); + Map profile = (Map) kakaoAccount.get("profile"); + return OAuth2Attribute.builder() + .email(kakaoAccount.get("email").toString()) + .emailType(EmailType.KAKAO.name()) + .attributes(kakaoAccount) + .attributeId(attributes.get(userNameAttributeName).toString()) + .name(profile.get("nickname").toString()) + .build(); + } + /* + * Naver 로그인일 경우 사용하는 메서드, 필요한 사용자 정보가 response Map에 감싸져 있어서, + * 한번 get() 메서드를 이용해 사용자 정보를 담고있는 Map을 꺼내야한다. + * */ + private static OAuth2Attribute ofNaver(String provider, String userNameAttributeName, Map attributes) { + Map response = (Map) attributes.get(userNameAttributeName); + + return OAuth2Attribute.builder() + .email( response.get("email").toString()) + .emailType(provider.toUpperCase()) + .attributes(response) + .attributeId( response.get("id").toString()) + .name(response.get("name").toString()) + .build(); + } + + /** OAuth2User 객체에 넣어주기 위해서 Map으로 값들을 반환 */ + public Map convertToMap() { + Map map = new HashMap<>(); + map.put("id", attributeId); + map.put("emailType", emailType); + map.put("email", email); + map.put("name", name); + return map; + } +} \ No newline at end of file diff --git a/src/main/java/com/chukapoka/server/common/authority/oauth2/handler/CustomAuthenticationFailHandler.java b/src/main/java/com/chukapoka/server/common/authority/oauth2/handler/CustomAuthenticationFailHandler.java new file mode 100644 index 0000000..f581156 --- /dev/null +++ b/src/main/java/com/chukapoka/server/common/authority/oauth2/handler/CustomAuthenticationFailHandler.java @@ -0,0 +1,19 @@ +package com.chukapoka.server.common.authority.oauth2.handler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAuthenticationFailHandler implements AuthenticationFailureHandler { + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { + /** 인증 실패시 메인 url로 이동 */ + response.sendRedirect("http://localhost:8080/"); + } +} \ No newline at end of file diff --git a/src/main/java/com/chukapoka/server/common/authority/oauth2/handler/CustomAuthenticationSuccessHandler.java b/src/main/java/com/chukapoka/server/common/authority/oauth2/handler/CustomAuthenticationSuccessHandler.java new file mode 100644 index 0000000..baf1ee2 --- /dev/null +++ b/src/main/java/com/chukapoka/server/common/authority/oauth2/handler/CustomAuthenticationSuccessHandler.java @@ -0,0 +1,65 @@ +package com.chukapoka.server.common.authority.oauth2.handler; + +import com.chukapoka.server.common.authority.jwt.JwtTokenProvider; +import com.chukapoka.server.common.dto.BaseResponse; +import com.chukapoka.server.common.dto.CustomUserDetails; +import com.chukapoka.server.common.dto.TokenDto; +import com.chukapoka.server.common.dto.TokenResponseDto; +import com.chukapoka.server.common.entity.Token; +import com.chukapoka.server.common.enums.ResultType; +import com.chukapoka.server.common.repository.TokenRepository; +import com.chukapoka.server.user.dto.UserResponseDto; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.core.Authentication; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + + +/** OAuth2 인증이 성공했을 경우 */ +@Slf4j +@Component +@RequiredArgsConstructor +public class CustomAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private final JwtTokenProvider jwtTokenProvider; + private final TokenRepository tokenRepository; + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException { + + CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + // 1. authentication 유저 정보에 따른 토큰 생성 + TokenResponseDto token = saveToken(authentication, userDetails.getUser().getId().toString()); + // 2. baseResponse 객체 생성 + UserResponseDto userResponseDto = new UserResponseDto(ResultType.SUCCESS, userDetails.getEmail(), userDetails.getUserId(), token); + BaseResponse baseResponse = new BaseResponse<>(ResultType.SUCCESS, userResponseDto); + // 3. JSON 형식으로 변환 + ObjectMapper objectMapper = new ObjectMapper(); + String jsonResponse = objectMapper.writeValueAsString(baseResponse); + // 4. 응답 헤더 설정 + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + // 5. 클라이언트에게 응답 전송 + response.getWriter().write(jsonResponse); + } + + /** 토큰 생성 */ + private TokenResponseDto saveToken(Authentication authentication, String id){ + System.out.println("OAuth2 토큰 생성중"); + // JWT 토큰 생성 + TokenDto jwtToken = jwtTokenProvider.createToken(authentication); + Token token = Token.builder() + .key(id) + .atValue(jwtToken.getAccessToken()) + .rtValue(jwtToken.getRefreshToken()) + .atExpiration(jwtToken.getAtExpiration()) + .rtExpiration(jwtToken.getRtExpiration()) + .build(); + return tokenRepository.save(token).toResponseDto(); + } +} \ No newline at end of file diff --git a/src/main/java/com/chukapoka/server/common/authority/oauth2/service/CustomOAuth2UserService.java b/src/main/java/com/chukapoka/server/common/authority/oauth2/service/CustomOAuth2UserService.java new file mode 100644 index 0000000..1f899fd --- /dev/null +++ b/src/main/java/com/chukapoka/server/common/authority/oauth2/service/CustomOAuth2UserService.java @@ -0,0 +1,79 @@ +package com.chukapoka.server.common.authority.oauth2.service; + +import com.chukapoka.server.common.authority.oauth2.dto.OAuth2Attribute; +import com.chukapoka.server.common.dto.CustomUserDetails; +import com.chukapoka.server.common.enums.Authority; +import com.chukapoka.server.user.entity.User; +import com.chukapoka.server.user.repository.UserRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserService; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import java.util.Map; +import java.util.Optional; + +@Slf4j +@Service +@Transactional +@RequiredArgsConstructor +public class CustomOAuth2UserService implements OAuth2UserService { + private final UserRepository userRepository; + private final BCryptPasswordEncoder bCryptPasswordEncoder = new BCryptPasswordEncoder(); + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + log.debug("Loading user from OAuth2: {}", userRequest); + + // 1. 기본 OAuth2UserService 객체 생성 + OAuth2UserService oAuth2UserService = new DefaultOAuth2UserService(); + System.out.println("oAuth2UserService = " + oAuth2UserService); + // 2. OAuth2UserService를 사용하여 OAuth2User 정보를 가져온다. + OAuth2User oAuth2User = oAuth2UserService.loadUser(userRequest); + System.out.println("oAuth2User = " + oAuth2User); + // 3. 클라이언트 등록 ID(google, naver, kakao)와 사용자 이름 속성을 가져온다. + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + String userNameAttributeName = userRequest.getClientRegistration() + .getProviderDetails().getUserInfoEndpoint().getUserNameAttributeName(); + + // 4. OAuth2UserService를 사용하여 가져온 OAuth2User 정보로 OAuth2Attribute 객체를 만든다. + OAuth2Attribute oAuth2Attribute = + OAuth2Attribute.of(registrationId, userNameAttributeName, oAuth2User.getAttributes()); + + // 5. OAuth2Attribute의 속성값들을 Map으로 반환 받는다. + Map memberAttribute = oAuth2Attribute.convertToMap(); + System.out.println("memberAttribute = " + memberAttribute); + // 6. 사용자 email(또는 id) 정보를 가져온다. + String email = (String) memberAttribute.get("email"); + log.debug("Email retrieved from OAuth2 attributes: {}", email); + + // 7. 이메일로 가입된 회원인지 조회한다. + Optional findMember = userRepository.findByEmail(email); + + + User user; + /** 회원이 존재하지 않을 경우 */ + if (findMember.isEmpty()) { + // user의 패스워드가 null이기 때문에 OAuth 유저는 일반적인 로그인을 할 수 없음. + user = User.builder() + .email(email) + .emailType((String) memberAttribute.get("emailType")) + .authorities("ROLE_"+Authority.USER.getAuthority()) + .password(bCryptPasswordEncoder.encode((String) memberAttribute.get("id"))) + .build(); + userRepository.save(user); + } + + /** 회원이 존재할 경우 */ + else { + user = findMember.get(); + user.setEmail(email); // Email이 변경 될 경우 업데이트 + } + return new CustomUserDetails(user, memberAttribute); + } +} \ No newline at end of file diff --git a/src/main/java/com/chukapoka/server/common/dto/CustomUserDetails.java b/src/main/java/com/chukapoka/server/common/dto/CustomUserDetails.java index da48094..dd322d0 100644 --- a/src/main/java/com/chukapoka/server/common/dto/CustomUserDetails.java +++ b/src/main/java/com/chukapoka/server/common/dto/CustomUserDetails.java @@ -5,24 +5,31 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.core.user.OAuth2User; import java.util.Collection; import java.util.Collections; +import java.util.Map; /** * CustomUserDetails 클래스는 Spring Security에서 제공하는 User 클래스를 확장하여 추가적인 사용자 정보를 저장하기 위한 클래스 * 주로 사용자의 고유한 식별자(ID)를 추가로 저장하고자 할 때 사용 */ @Getter -public class CustomUserDetails implements UserDetails { +public class CustomUserDetails implements UserDetails, OAuth2User { private final User user; - + private Map attributes; /** 일반 로그인 */ public CustomUserDetails(User user) { this.user = user; } + /** OAuth 로그인 */ + public CustomUserDetails(User user, Map attributes) { + this.user = user; + this.attributes = attributes; + } @Override public Collection getAuthorities() { if (user == null) { @@ -51,7 +58,18 @@ public String getUsername() { } return null; // 사용자 객체가 null인 경우 null 반환 } + @Override + public Map getAttributes() { + return attributes; + } + /** tokenDB 값에서 key값을 바꾸고 싶을떄 + * Authentication 객체의 값을 UserDetails 에서 가져온다. + */ + @Override + public String getName() { + return (String) attributes.get("id"); + } /** 계정의 만료 여부 반환 (기한이 없으므로 항상 true 반환) */ @Override