diff --git a/build.gradle b/build.gradle index 24607afb..7d842b93 100644 --- a/build.gradle +++ b/build.gradle @@ -44,6 +44,11 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-api:0.11.2' implementation 'io.jsonwebtoken:jjwt-impl:0.11.2' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.2' + + // OAuth2 login + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + } tasks.named('test') { diff --git a/src/main/java/com/example/umc7th/Umc7thApplication.java b/src/main/java/com/example/umc7th/Umc7thApplication.java index 55c171b0..3c7427b0 100644 --- a/src/main/java/com/example/umc7th/Umc7thApplication.java +++ b/src/main/java/com/example/umc7th/Umc7thApplication.java @@ -1,11 +1,14 @@ package com.example.umc7th; +import com.example.umc7th.domain.oauth2.OAuth2Properties; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @SpringBootApplication @EnableJpaAuditing +@EnableConfigurationProperties(OAuth2Properties.class) public class Umc7thApplication { public static void main(String[] args) { diff --git a/src/main/java/com/example/umc7th/domain/article/controller/ArticleController.java b/src/main/java/com/example/umc7th/domain/article/controller/ArticleController.java index 8ef8e5b7..78e1d2ba 100644 --- a/src/main/java/com/example/umc7th/domain/article/controller/ArticleController.java +++ b/src/main/java/com/example/umc7th/domain/article/controller/ArticleController.java @@ -13,9 +13,13 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Slice; import org.springframework.web.bind.annotation.*; +import java.time.LocalDateTime; +import java.util.List; + @RestController @RequiredArgsConstructor @Tag(name = "게시글 API") // Swagger에 표시될 API 그룹 이름 @@ -66,24 +70,6 @@ public CustomResponse getArticles(@Req @RequestParam(value = "offset", defaultValue = "10") Integer offset) { Slice
articles = articleQueryService.getArticles(query, cursor, offset); return CustomResponse.onSuccess(ArticleResponseDTO.ArticlePreviewListDTO.from(articles)); - }*/ - - /** - * 커서 기반 게시글 조회 API - * @param lastCreatedAt (이전 게시글의 생성 날짜) - * @param pageable (페이지네이션 정보) - * @return 생성 날짜 기준으로 게시글을 조회한 후 성공 응답을 CustomResponse 형태로 반환 - */ - @GetMapping("/articles/cursor") - @Operation(summary = "커서 기반 게시글 조회 API", description = "생성 날짜 기준으로 게시글 조회하는 API") - public CustomResponse getArticlesByCursor( - @RequestParam(required = false) LocalDateTime lastCreatedAt, - Pageable pageable) { - - List
articles = articleQueryService.getArticlesByCreatedAtLessThan(lastCreatedAt, pageable); - int totalCount = (int) articleRepository.count(); // 전체 게시글 수 조회 - - return CustomResponse.onSuccess(ArticleResponseDTO.ArticlePreviewListDTO.from(articles, totalCount, pageable.getPageSize())); } /** 게시물 수정 API */ diff --git a/src/main/java/com/example/umc7th/domain/article/service/query/ArticleQueryServiceImpl.java b/src/main/java/com/example/umc7th/domain/article/service/query/ArticleQueryServiceImpl.java index e13dad16..f13d0149 100644 --- a/src/main/java/com/example/umc7th/domain/article/service/query/ArticleQueryServiceImpl.java +++ b/src/main/java/com/example/umc7th/domain/article/service/query/ArticleQueryServiceImpl.java @@ -58,9 +58,4 @@ public Article getArticle(Long id) { new ArticleException(ArticleErrorCode.NOT_FOUND)); } - @Override - public List
getArticlesByCreatedAtLessThan(LocalDateTime createdAt, Pageable pageable) { - // 생성 날짜 기준으로 게시글 조회 - return articleRepository.findByCreatedAtLessThan(createdAt, pageable); - } } \ No newline at end of file diff --git a/src/main/java/com/example/umc7th/domain/member/exception/MemberErrorCode.java b/src/main/java/com/example/umc7th/domain/member/exception/MemberErrorCode.java index 68bb732c..e5f5c5a3 100644 --- a/src/main/java/com/example/umc7th/domain/member/exception/MemberErrorCode.java +++ b/src/main/java/com/example/umc7th/domain/member/exception/MemberErrorCode.java @@ -12,6 +12,8 @@ public enum MemberErrorCode implements BaseErrorCode { NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404", "사용자를 찾을 수 없습니다."), ALREADY_EXIST(HttpStatus.BAD_REQUEST, "MEMBER400", "이미 존재하는 사용자입니다."), INCORRECT_PASSWORD(HttpStatus.UNAUTHORIZED, "MEMBER401", "비밀번호가 틀립니다."), + OAUTH_USER_INFO_FAIL(HttpStatus.BAD_REQUEST, "MEMBER400", "OAuth 사용자 정보를 가져오는 데 실패했습니다."), + OAUTH_TOKEN_FAIL(HttpStatus.UNAUTHORIZED, "OAUTH401", "OAuth 토큰을 가져오는 데 실패했습니다."), ; private final HttpStatus status; diff --git a/src/main/java/com/example/umc7th/domain/oauth2/OAuth2Properties.java b/src/main/java/com/example/umc7th/domain/oauth2/OAuth2Properties.java new file mode 100644 index 00000000..9979aaa1 --- /dev/null +++ b/src/main/java/com/example/umc7th/domain/oauth2/OAuth2Properties.java @@ -0,0 +1,38 @@ +package com.example.umc7th.domain.oauth2; + +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; + +@Configuration +@ConfigurationProperties(prefix = "spring.security.oauth2.client") +@Data +@Primary +public class OAuth2Properties { + + private Registration registration; + private Provider provider; + + @Data + public static class Registration { + private Kakao kakao; + + @Data + public static class Kakao { + private String clientId; + private String redirectUri; + } + } + + @Data + public static class Provider { + private Kakao kakao; + + @Data + public static class Kakao { + private String tokenUri; + private String userInfoUri; + } + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc7th/domain/oauth2/controller/oauth2controller.java b/src/main/java/com/example/umc7th/domain/oauth2/controller/oauth2controller.java new file mode 100644 index 00000000..37c0af01 --- /dev/null +++ b/src/main/java/com/example/umc7th/domain/oauth2/controller/oauth2controller.java @@ -0,0 +1,19 @@ +package com.example.umc7th.domain.oauth2.controller; + +import com.example.umc7th.domain.member.dto.MemberResponseDTO; +import com.example.umc7th.global.apiPayload.CustomResponse; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestParam; + +public class oauth2controller { + @GetMapping("/oauth2/callback/kakao") + // queryParam 형식으로 코드를 받을 예정이니 RequestParam을 설정해줍니다 + // 응답은 저희 서버에 로그인 다 한 뒤에 토큰을 제공할 예정이니 TokenDTO로 설정해줍니다. + public CustomResponse loginWithKakao(@RequestParam("code") String code) { + // 로직 구현 필요 + return null; + // 서비스 생성 이후 + // return CustomResponse.onSuccess(oAuth2Service.login(code)); + + } +} diff --git a/src/main/java/com/example/umc7th/domain/oauth2/dto/OAuth2DTO.java b/src/main/java/com/example/umc7th/domain/oauth2/dto/OAuth2DTO.java new file mode 100644 index 00000000..ce8be227 --- /dev/null +++ b/src/main/java/com/example/umc7th/domain/oauth2/dto/OAuth2DTO.java @@ -0,0 +1,54 @@ +package com.example.umc7th.domain.oauth2.dto; + +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/umc7th/domain/oauth2/service/OAuth2Service.java b/src/main/java/com/example/umc7th/domain/oauth2/service/OAuth2Service.java new file mode 100644 index 00000000..8f53b5c9 --- /dev/null +++ b/src/main/java/com/example/umc7th/domain/oauth2/service/OAuth2Service.java @@ -0,0 +1,7 @@ +package com.example.umc7th.domain.oauth2.service; + +import com.example.umc7th.domain.member.dto.MemberResponseDTO; + +public interface OAuth2Service { + MemberResponseDTO.MemberTokenDTO login(String code); +} diff --git a/src/main/java/com/example/umc7th/domain/oauth2/service/OAuth2ServiceImpl.java b/src/main/java/com/example/umc7th/domain/oauth2/service/OAuth2ServiceImpl.java new file mode 100644 index 00000000..09fb5549 --- /dev/null +++ b/src/main/java/com/example/umc7th/domain/oauth2/service/OAuth2ServiceImpl.java @@ -0,0 +1,77 @@ +package com.example.umc7th.domain.oauth2.service; + +import com.example.umc7th.domain.member.dto.MemberResponseDTO; +import com.example.umc7th.domain.member.entity.Member; +import com.example.umc7th.domain.member.exception.MemberException; +import com.example.umc7th.domain.member.repository.MemberRepository; +import com.example.umc7th.domain.oauth2.OAuth2Properties; +import com.example.umc7th.domain.oauth2.dto.OAuth2DTO; +import com.example.umc7th.global.jwt.util.JwtProvider; +import com.example.umc7th.domain.member.exception.MemberErrorCode; +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; +import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.WebClient; + + +@Service +@RequiredArgsConstructor +public class OAuth2ServiceImpl implements OAuth2Service { + + private final MemberRepository memberRepository; + private final JwtProvider jwtProvider; + private final OAuth2Properties oAuth2Properties; + + @Override + public MemberResponseDTO.MemberTokenDTO login(String code) { + // WebClient 사용해 Token URI 요청 및 응답 처리 + WebClient webClient = WebClient.builder().build(); + + // Token 요청 설정 + OAuth2DTO.OAuth2TokenDTO oAuth2TokenDTO = webClient.post() + .uri(oAuth2Properties.getProvider().getKakao().getTokenUri()) + .header(HttpHeaders.CONTENT_TYPE, "application/x-www-form-urlencoded") + .body(BodyInserters.fromFormData("grant_type", "authorization_code") + .with("client_id", oAuth2Properties.getRegistration().getKakao().getClientId()) + .with("redirect_uri", oAuth2Properties.getRegistration().getKakao().getRedirectUri()) + .with("code", code)) + .retrieve() + .bodyToMono(OAuth2DTO.OAuth2TokenDTO.class) + .block(); + + // 토큰을 이용해 사용자 정보 요청 + OAuth2DTO.KakaoProfile profile = webClient.get() + .uri(oAuth2Properties.getProvider().getKakao().getUserInfoUri()) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + oAuth2TokenDTO.getAccess_token()) + .retrieve() + .bodyToMono(OAuth2DTO.KakaoProfile.class) + .block(); + + // `email`로 회원 검색 및 가입 처리 + String email = profile.getKakao_account().getEmail(); // email로 변경하여 가져옴 + + Member member = memberRepository.findByEmail(email).orElseGet(() -> + memberRepository.save( + Member.builder() + .email(email) + .role("ROLE_USER") + .build() + ) + ); + + // JWT 토큰 생성 후 반환 + return MemberResponseDTO.MemberTokenDTO.builder() + .accessToken(jwtProvider.createAccessToken(member)) + .refreshToken(jwtProvider.createRefreshToken(member)) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc7th/domain/reply/controller/ReplyController.java b/src/main/java/com/example/umc7th/domain/reply/controller/ReplyController.java index dd8a09c9..e2907f53 100644 --- a/src/main/java/com/example/umc7th/domain/reply/controller/ReplyController.java +++ b/src/main/java/com/example/umc7th/domain/reply/controller/ReplyController.java @@ -41,34 +41,8 @@ public CustomResponse getReplies(@PathVari // 페이지와 오프셋을 기반으로 특정 게시글의 댓글 목록을 조회하여 응답 DTO로 변환 Page replies = replyQueryService.getReplies(articleId, page, offset); return CustomResponse.onSuccess(ReplyConverter.toReplyPreviewListDTO(replies)); - }*/ - - /** - * 댓글 전체 조회 API (Offset 기반 페이지네이션) - * @param page 페이지 번호 - * @param size 한 페이지당 댓글 수 - * @return 조회된 페이지네이션 댓글 목록을 CustomResponse로 반환 - */ - @GetMapping - @Operation(summary = "댓글 전체 조회 API", description = "Offset 기반 페이지네이션을 통해 댓글 전체를 조회하는 API") - public CustomResponse getReplies( - @RequestParam(defaultValue = "0") int page, - @RequestParam(defaultValue = "10") int size) { - - Page replyPage = replyQueryService.getRepliesWithPagination(page, size); - - // 응답 DTO 변환 및 페이지네이션 정보 설정 - ReplyResponseDTO.ReplyPreviewListDTO response = ReplyConverter.toReplyPreviewListDTO( - replyPage.getContent(), - replyPage.getSize(), - replyPage.getNumber(), - (int) replyPage.getTotalElements() - ); - - return CustomResponse.onSuccess(response); } - /** 댓글 하나 조회 API */ @GetMapping("/{replyId}") @Operation(summary = "댓글 조회 API", description = "댓글 하나 조회하는 API") diff --git a/src/main/java/com/example/umc7th/domain/reply/service/query/ReplyQueryService.java b/src/main/java/com/example/umc7th/domain/reply/service/query/ReplyQueryService.java index 4dbd16ce..a6e4cbca 100644 --- a/src/main/java/com/example/umc7th/domain/reply/service/query/ReplyQueryService.java +++ b/src/main/java/com/example/umc7th/domain/reply/service/query/ReplyQueryService.java @@ -9,5 +9,4 @@ public interface ReplyQueryService { Page getReplies(Long articleId, Integer page, Integer offset); Reply getReply(Long id); - Page getRepliesWithPagination(int page, int size); } \ No newline at end of file diff --git a/src/main/java/com/example/umc7th/domain/reply/service/query/ReplyQueryServiceImpl.java b/src/main/java/com/example/umc7th/domain/reply/service/query/ReplyQueryServiceImpl.java index ece6c93e..dde30bfa 100644 --- a/src/main/java/com/example/umc7th/domain/reply/service/query/ReplyQueryServiceImpl.java +++ b/src/main/java/com/example/umc7th/domain/reply/service/query/ReplyQueryServiceImpl.java @@ -46,11 +46,4 @@ public Page getReplies(Long articleId, Integer page, Integer offset) { // 특정 게시글에 대한 댓글 목록을 페이지 단위로 조회 return replyRepository.findAllByArticleIsOrderByCreatedAtDesc(article, pageable); } - - @Override - public Page getRepliesWithPagination(int page, int size) { - // 페이지 요청을 생성하고, 댓글을 페이지로 조회하여 반환 - Pageable pageable = PageRequest.of(page, size); - return replyRepository.findAllByOrderByCreatedAtDesc(pageable); - } } \ No newline at end of file diff --git a/src/main/java/com/example/umc7th/global/config/SecurityConfig.java b/src/main/java/com/example/umc7th/global/config/SecurityConfig.java index b94955b7..05763ba5 100644 --- a/src/main/java/com/example/umc7th/global/config/SecurityConfig.java +++ b/src/main/java/com/example/umc7th/global/config/SecurityConfig.java @@ -8,6 +8,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.configurers.AbstractHttpConfigurer; import org.springframework.security.config.annotation.web.configurers.HttpBasicConfigurer; @@ -38,7 +39,8 @@ public class SecurityConfig { "/swagger-resources/**", "/v3/api-docs/**", "/login", - "/signup" + "/signup", + "/oauth2/callback/**" }; @Bean // Spring Security의 필터 체인을 설정하는 빈으로 등록 @@ -56,6 +58,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception{ .formLogin(AbstractHttpConfigurer::disable) // 기본 HTTP Basic 인증을 비활성화 .httpBasic(HttpBasicConfigurer::disable) + + // OAuth2 Login 설정을 default로 설정 + .oauth2Login(Customizer.withDefaults()) + // CSRF 보안을 비활성화 .csrf(AbstractHttpConfigurer::disable) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 623164a8..a944f6d6 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,6 +10,26 @@ spring: properties: hibernate: format_sql: true + # OAuth2 login + security: + oauth2: + client: + registration: + kakao: + authorization-grant-type: authorization_code + client-id: ${API_KEY} + redirect-uri: ${REDIRECT_URI} + client-authentication-method: client_secret_post + scope: + - profile_nickname + - profile_image + - account_email + provider: + kakao: + authorization-uri: https://kauth.kakao.com/oauth/authorize + token-uri: https://kauth.kakao.com/oauth/token + user-info-uri: https://kapi.kakao.com/v2/user/me + user-name-attribute: id Jwt: secret: ${JWT_SECRET}