diff --git a/build.gradle b/build.gradle index b1e5245..d5c7190 100644 --- a/build.gradle +++ b/build.gradle @@ -45,6 +45,9 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.12.3' implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + + // OAuth2 login + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' } tasks.named('test') { diff --git a/src/main/java/com/example/umc9th/config/SecurityConfig.java b/src/main/java/com/example/umc9th/config/SecurityConfig.java index d1c2fc9..9124a1d 100644 --- a/src/main/java/com/example/umc9th/config/SecurityConfig.java +++ b/src/main/java/com/example/umc9th/config/SecurityConfig.java @@ -1,5 +1,6 @@ package com.example.umc9th.config; +import com.example.umc9th.domain.member.service.command.CustomOAuth2UserService; import com.example.umc9th.global.jwt.JwtFilter; import com.example.umc9th.global.jwt.JwtUtil; import com.example.umc9th.global.auth.CustomUserDetailsService; @@ -8,6 +9,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; @@ -36,11 +38,13 @@ public class SecurityConfig { private final CustomUserDetailsService customUserDetailsService; private final CustomEntryPoint customEntryPoint; private final CustomAccessDeniedHandler customAccessDeniedHandler; + private final CustomOAuth2UserService customOAuth2UserService; // 아래 3개는 Swagger에 대한 URL private String[] allowUrl = { "/auth/sign-up", "/auth/login", // 로그인 URL 추가 + "/oauth2/**", "/swagger-ui/**", "/swagger-resources/**", "/v3/api-docs/**", @@ -64,6 +68,12 @@ SecurityFilterChain filterChain(HttpSecurity http, CorsConfigurationSource corsC .authenticationEntryPoint(customEntryPoint) .accessDeniedHandler(customAccessDeniedHandler) ) + .oauth2Login((oauth2) -> oauth2 + .defaultSuccessUrl("/swagger-ui/index.html", true) // 로그인 성공 시 리디렉션될 경로 + .userInfoEndpoint((userInfoEndpointConfig) -> userInfoEndpointConfig + .userService(customOAuth2UserService)) + .redirectionEndpoint(endpoint -> endpoint + .baseUri("/oauth2/callback/*"))) // // formLogin 설정 // .formLogin(formLogin -> formLogin // // Form login에서 사용하는 SecurityContextRepository 설정 diff --git a/src/main/java/com/example/umc9th/domain/member/controller/MemberController.java b/src/main/java/com/example/umc9th/domain/member/controller/MemberController.java index 183bdbe..8547df4 100644 --- a/src/main/java/com/example/umc9th/domain/member/controller/MemberController.java +++ b/src/main/java/com/example/umc9th/domain/member/controller/MemberController.java @@ -4,6 +4,7 @@ import com.example.umc9th.domain.member.dto.res.MemberResponseDTO; import com.example.umc9th.domain.member.entity.Member; import com.example.umc9th.domain.member.service.command.MemberCommandService; +import com.example.umc9th.domain.member.service.command.OAuth2Service; import com.example.umc9th.global.apiPayload.ApiResponse; import com.example.umc9th.global.apiPayload.code.GeneralSuccessCode; import lombok.RequiredArgsConstructor; @@ -11,22 +12,26 @@ @RestController -@RequestMapping("/auth") @RequiredArgsConstructor public class MemberController { private final MemberCommandService memberCommandService; + private final OAuth2Service oAuth2Service; - @PostMapping("/sign-up") + @PostMapping("/auth/sign-up") public ApiResponse signUp(@RequestBody MemberRequestDTO.SignUpRequestDTO dto) { Member member = memberCommandService.signUp(dto); MemberResponseDTO.SignUpResponseDTO responseDTO = MemberResponseDTO.SignUpResponseDTO.from(member); return ApiResponse.onSuccess(GeneralSuccessCode.OK, responseDTO); } - @PostMapping("/login") + @PostMapping("/auth/login") public ApiResponse signin(@RequestBody MemberRequestDTO.LoginRequestDTO dto){ MemberResponseDTO.LoginResponseDTO responseDTO = memberCommandService.login(dto); return ApiResponse.onSuccess(GeneralSuccessCode.OK, responseDTO); } +// @GetMapping("/oauth2/callback/kakao") +// public ApiResponse loginWithKakao(@RequestParam("code") String code) { +// return ApiResponse.onSuccess(GeneralSuccessCode.OK, oAuth2Service.login(code)); +// } } \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/domain/member/dto/res/CustomOAuth2User.java b/src/main/java/com/example/umc9th/domain/member/dto/res/CustomOAuth2User.java new file mode 100644 index 0000000..bf06719 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/member/dto/res/CustomOAuth2User.java @@ -0,0 +1,41 @@ +package com.example.umc9th.domain.member.dto.res; + +import com.example.umc9th.domain.member.entity.ROLE; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +@RequiredArgsConstructor +public class CustomOAuth2User implements OAuth2User { + + private final OAuth2Response oAuthResponse; + private final ROLE role; + + @Override + public Collection getAuthorities() { + Collection collection = new ArrayList<>(); + + collection.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return role.toString(); + } + }); + + return collection; + } + + @Override + public Map getAttributes() { + return null; + } + + @Override + public String getName() { + return oAuthResponse.getEmail(); + } +} diff --git a/src/main/java/com/example/umc9th/domain/member/dto/res/KakaoResponse.java b/src/main/java/com/example/umc9th/domain/member/dto/res/KakaoResponse.java new file mode 100644 index 0000000..47c994b --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/member/dto/res/KakaoResponse.java @@ -0,0 +1,46 @@ +package com.example.umc9th.domain.member.dto.res; + +import lombok.RequiredArgsConstructor; + +import java.util.Map; + +@RequiredArgsConstructor +public class KakaoResponse implements OAuth2Response { + + private final Map attribute; + + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getProviderId() { + return attribute.get("id").toString(); + } + + @Override + public String getEmail() { + Map account = (Map) attribute.get("kakao_account"); + if (account == null) { + return null; + } + return account.get("email").toString(); + } + + @Override + public String getName() { + Map account = (Map) attribute.get("kakao_account"); + if (account == null) { + return null; + } + Map profile = (Map) account.get("profile"); + + if (profile == null) { + return null; + } + + return profile.get("nickname").toString(); + } +} diff --git a/src/main/java/com/example/umc9th/domain/member/dto/res/OAuth2DTO.java b/src/main/java/com/example/umc9th/domain/member/dto/res/OAuth2DTO.java new file mode 100644 index 0000000..24d6214 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/member/dto/res/OAuth2DTO.java @@ -0,0 +1,53 @@ +package com.example.umc9th.domain.member.dto.res; + +import lombok.Getter; + +public class OAuth2DTO { + + @Getter + public static class OAuth2TokenDTO { + String token_type; + String access_token; + String refresh_token; + Long expires_in; + Long refresh_token_expires_in; + String scope; + } + + @Getter + public static class KakaoProfile { + private Long id; + private String connected_at; + private Properties properties; + private KakaoAccount kakao_account; + + @Getter + public class Properties { + private String nickname; + private String profile_image; + private String thumbnail_image; + } + + @Getter + public class KakaoAccount { + private String email; + private Boolean is_email_verified; + private Boolean email_needs_agreement; + private Boolean has_email; + private Boolean profile_nickname_needs_agreement; + private Boolean profile_image_needs_agreement; + private Boolean email_needs_argument; + private Boolean is_email_valid; + private Profile profile; + + @Getter + public class Profile { + private String nickname; + private String thumbnail_image_url; + private String profile_image_url; + private Boolean is_default_nickname; + private Boolean is_default_image; + } + } + } +} diff --git a/src/main/java/com/example/umc9th/domain/member/dto/res/OAuth2Response.java b/src/main/java/com/example/umc9th/domain/member/dto/res/OAuth2Response.java new file mode 100644 index 0000000..bbec943 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/member/dto/res/OAuth2Response.java @@ -0,0 +1,9 @@ +package com.example.umc9th.domain.member.dto.res; + +public interface OAuth2Response { + + String getProvider(); + String getProviderId(); + String getEmail(); + String getName(); +} diff --git a/src/main/java/com/example/umc9th/domain/member/entity/Member.java b/src/main/java/com/example/umc9th/domain/member/entity/Member.java index cf06465..6830b42 100644 --- a/src/main/java/com/example/umc9th/domain/member/entity/Member.java +++ b/src/main/java/com/example/umc9th/domain/member/entity/Member.java @@ -15,10 +15,17 @@ public class Member { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name = "email", unique = true, nullable = false) + private String email; + @Column(name = "username", unique = true, nullable = false) private String username; - @Column(name = "password", nullable = false) + @Column(name = "password") private String password; + @Column(name = "role") + @Enumerated(EnumType.STRING) + @Builder.Default + private ROLE role = ROLE.USER; } \ No newline at end of file diff --git a/src/main/java/com/example/umc9th/domain/member/entity/ROLE.java b/src/main/java/com/example/umc9th/domain/member/entity/ROLE.java new file mode 100644 index 0000000..948d927 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/member/entity/ROLE.java @@ -0,0 +1,5 @@ +package com.example.umc9th.domain.member.entity; + +public enum ROLE { + USER; +} diff --git a/src/main/java/com/example/umc9th/domain/member/exception/code/MemberErrorCode.java b/src/main/java/com/example/umc9th/domain/member/exception/code/MemberErrorCode.java index f3cdb67..34474d0 100644 --- a/src/main/java/com/example/umc9th/domain/member/exception/code/MemberErrorCode.java +++ b/src/main/java/com/example/umc9th/domain/member/exception/code/MemberErrorCode.java @@ -11,9 +11,12 @@ public enum MemberErrorCode implements BaseErrorCode { //401 BAD_CREDENTIAL(HttpStatus.UNAUTHORIZED, "MEMBER401_1", "유효한 인증 자격이 아닙니다."), + OAUTH_TOKEN_FAIL(HttpStatus.UNAUTHORIZED, "MEMBER401_2", "소셜 로그인 실패"), + OAUTH_USER_INFO_FAIL(HttpStatus.UNAUTHORIZED, "MEMBER401_3", "소셜 로그인에서 사용자 정보를 가져오지 못했습니다."), //404 - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 ID의 맴버를 찾을 수 없습니다."); + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 ID의 맴버를 찾을 수 없습니다.") + ; private final HttpStatus status; private final String code; diff --git a/src/main/java/com/example/umc9th/domain/member/repository/MemberRepository.java b/src/main/java/com/example/umc9th/domain/member/repository/MemberRepository.java index dfdb713..edcf828 100644 --- a/src/main/java/com/example/umc9th/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/example/umc9th/domain/member/repository/MemberRepository.java @@ -7,4 +7,6 @@ public interface MemberRepository extends JpaRepository { Optional findByUsername(String username); + + Optional findByEmail(String email); } diff --git a/src/main/java/com/example/umc9th/domain/member/service/command/CustomOAuth2UserService.java b/src/main/java/com/example/umc9th/domain/member/service/command/CustomOAuth2UserService.java new file mode 100644 index 0000000..e400c57 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/member/service/command/CustomOAuth2UserService.java @@ -0,0 +1,54 @@ +package com.example.umc9th.domain.member.service.command; + +import com.example.umc9th.domain.member.dto.res.CustomOAuth2User; +import com.example.umc9th.domain.member.dto.res.KakaoResponse; +import com.example.umc9th.domain.member.dto.res.OAuth2Response; +import com.example.umc9th.domain.member.entity.Member; +import com.example.umc9th.domain.member.entity.ROLE; +import com.example.umc9th.domain.member.exception.code.MemberErrorCode; +import com.example.umc9th.domain.member.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + + private final MemberRepository memberRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException{ + OAuth2User oAuth2User = super.loadUser(userRequest); // 부모 클래스의 메서드를 통해 유저 정보를 받아옴 + log.info("getAttributes : {}", oAuth2User.getAttributes()); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + OAuth2Response oAuthResponse = null; + if(registrationId.equals("kakao")){ + oAuthResponse = new KakaoResponse(oAuth2User.getAttributes()); + } + else{ + // 다른 소셜 로그인 시 확장 가능 + throw new OAuth2AuthenticationException(MemberErrorCode.OAUTH_USER_INFO_FAIL.getCode()); + } + String email = oAuthResponse.getEmail(); + Member existData = memberRepository.findByEmail(email).orElse(null); + ROLE role = ROLE.USER; + + if(existData == null){ + Member member = Member.builder() + .username(oAuthResponse.getName()) + .email(email) + .password(null) + .role(role) + .build(); + memberRepository.save(member); + } + return new CustomOAuth2User(oAuthResponse, role); + } +} diff --git a/src/main/java/com/example/umc9th/domain/member/service/command/OAuth2Service.java b/src/main/java/com/example/umc9th/domain/member/service/command/OAuth2Service.java new file mode 100644 index 0000000..11a89a6 --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/member/service/command/OAuth2Service.java @@ -0,0 +1,8 @@ +package com.example.umc9th.domain.member.service.command; + +import com.example.umc9th.domain.member.dto.res.MemberResponseDTO; + +public interface OAuth2Service { + + MemberResponseDTO.LoginResponseDTO login(String code); +} diff --git a/src/main/java/com/example/umc9th/domain/member/service/command/OAuth2ServiceImpl.java b/src/main/java/com/example/umc9th/domain/member/service/command/OAuth2ServiceImpl.java new file mode 100644 index 0000000..f8c761b --- /dev/null +++ b/src/main/java/com/example/umc9th/domain/member/service/command/OAuth2ServiceImpl.java @@ -0,0 +1,117 @@ +package com.example.umc9th.domain.member.service.command; + +import com.example.umc9th.domain.member.dto.res.MemberResponseDTO; +import com.example.umc9th.domain.member.dto.res.OAuth2DTO; +import com.example.umc9th.domain.member.entity.Member; +import com.example.umc9th.domain.member.entity.ROLE; +import com.example.umc9th.domain.member.exception.MemberException; +import com.example.umc9th.domain.member.exception.code.MemberErrorCode; +import com.example.umc9th.domain.member.repository.MemberRepository; +import com.example.umc9th.global.jwt.JwtUtil; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +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 OAuth2ServiceImpl implements OAuth2Service{ + + @Value("${spring.security.oauth2.client.provider.kakao.token-uri}") + private String tokenURI; // Resource Server에 토큰 요청시 사용할 URI + + @Value("${spring.security.oauth2.client.provider.kakao.user-info-uri}") + private String userInfoURI; // 사용자 정보 가져올 때 사용할 URI + + @Value("${spring.security.oauth2.client.registration.kakao.client-id}") + private String clientId; // API KEY + + @Value("${spring.security.oauth2.client.registration.kakao.redirect-uri}") + private String redirectURI; // 설정한 Redirect uri + + private final MemberRepository memberRepository; + private final JwtUtil jwtUtil; + + @Override + public MemberResponseDTO.LoginResponseDTO login(String code) { + // 인가코드 토큰 가져오기 + RestTemplate restTemplate = new RestTemplate(); // 요청을 보내기 위한 RestTemplate + HttpHeaders httpHeaders = new HttpHeaders(); // 헤더 선언 + + httpHeaders.add("Content-Type", "application/x-www-form-urlencoded"); // 헤더 설정 + + MultiValueMap map = new LinkedMultiValueMap<>(); // RequestBody 설정 + map.add("grant_type", "authorization_code"); + map.add("client_id", clientId); + map.add("redirect_uri", redirectURI); + map.add("code", code); + HttpEntity request = new HttpEntity<>(map, httpHeaders); // Header와 Body를 이용하여 요청에 보낼 HttpEntity 생성 + + // 요청을 보내서 응답 받아오기 + ResponseEntity response1 = restTemplate.exchange( + tokenURI, // URI + HttpMethod.POST, // Method + request, // Request 내용 + String.class); // 받을 응답 자료형 + + ObjectMapper objectMapper = new ObjectMapper(); // String을 OAuth2DTO.OAuth2TokenDTO로 변경하기 위해 ObjectMapper 선언 + OAuth2DTO.OAuth2TokenDTO oAuth2TokenDTO = null; + + try { + oAuth2TokenDTO = objectMapper.readValue(response1.getBody(), OAuth2DTO.OAuth2TokenDTO.class); + } catch (Exception e) { + throw new MemberException(MemberErrorCode.OAUTH_TOKEN_FAIL); // 토큰 DTO로 변경하지 못한 경우 Exception 보냄 + } + + // 토큰으로 정보 가져오기 + // 위와 흐름이 동일하여 생략하겠습니다. 아래는 RequestBody가 없어서 추가하지 않은 것을 볼 수 있습니다. + restTemplate = new RestTemplate(); + httpHeaders = new HttpHeaders(); + + httpHeaders.add("Authorization", "Bearer " + oAuth2TokenDTO.getAccess_token()); + httpHeaders.add("Content-Type", "application/x-www-form-urlencoded;charset=utf-8"); + + HttpEntity request1 = new HttpEntity<>(httpHeaders); + + ResponseEntity response2 = restTemplate.exchange( + userInfoURI, + HttpMethod.GET, + request1, + String.class + ); + + OAuth2DTO.KakaoProfile profile = null; + ObjectMapper om = new ObjectMapper(); + + try { + profile = om.readValue(response2.getBody(), OAuth2DTO.KakaoProfile.class); + } catch(Exception e) { + throw new MemberException(MemberErrorCode.OAUTH_USER_INFO_FAIL); // 사용자 정보를 가져오지 못한 경우 Exception 발생 + } + + // 회원가입이 되었으면 사용자 로그인 안되어있으면 회원가입 후 로그인 + String email = profile.getKakao_account().getEmail(); + + // email을 찾고 있으면 member에 넣고 없으면 새로 만들어서 저장하고 넣는다. + Member member = memberRepository.findByEmail(email).orElse( + memberRepository.save(Member.builder() + .username(profile.getProperties().getNickname()) + .email(email) + .role(ROLE.USER) + .build()) + ); + + // TokenDTO로 변경해서 저번 주차에 구현한 JWT 형태로 반환 + return MemberResponseDTO.LoginResponseDTO.builder() + .accessToken(jwtUtil.createAccessToken(member)) + .refreshToken(jwtUtil.createRefreshToken(member)) + .build(); + } +}