diff --git a/.github/ISSUE_TEMPLATE/needdelete.md b/.github/ISSUE_TEMPLATE/needdelete.md new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/needdelete.md @@ -0,0 +1 @@ + diff --git a/.github/workflows/deploy-admin.yml b/.github/workflows/deploy-admin.yml index 39de4768..5c9889c2 100644 --- a/.github/workflows/deploy-admin.yml +++ b/.github/workflows/deploy-admin.yml @@ -5,14 +5,14 @@ on: branches: - develop paths: - - 'nowait-app-admin-api/**' - - 'nowait-common/**' - - 'nowait-domain/domain-core-rdb/**' - - 'nowait-domain/domain-admin-rdb/**' - - 'nowait-infra/**' - - 'build.gradle' - - 'settings.gradle' - - 'gradle/**' + - 'nowait-app-admin-api/**' + - 'nowait-common/**' + - 'nowait-domain/domain-core-rdb/**' + - 'nowait-domain/domain-admin-rdb/**' + - 'nowait-infra/**' + - 'build.gradle' + - 'settings.gradle' + - 'gradle/**' env: PROJECT_NAME: NoWait diff --git a/.github/workflows/deploy-user.yml b/.github/workflows/deploy-user.yml index 70f31909..67b2f28f 100644 --- a/.github/workflows/deploy-user.yml +++ b/.github/workflows/deploy-user.yml @@ -5,14 +5,14 @@ on: branches: - develop paths: - - 'nowait-app-user-api/**' - - 'nowait-common/**' - - 'nowait-domain/domain-core-rdb/**' - - 'nowait-domain/domain-user-rdb/**' - - 'nowait-infra/**' - - 'build.gradle' - - 'settings.gradle' - - 'gradle/**' + - 'nowait-app-user-api/**' + - 'nowait-common/**' + - 'nowait-domain/domain-core-rdb/**' + - 'nowait-domain/domain-user-rdb/**' + - 'nowait-infra/**' + - 'build.gradle' + - 'settings.gradle' + - 'gradle/**' env: PROJECT_NAME: NoWait diff --git a/build.gradle b/build.gradle index 03ccdb50..94b2c6d6 100644 --- a/build.gradle +++ b/build.gradle @@ -19,7 +19,9 @@ subprojects { apply plugin: "java" apply plugin: 'java-library' apply plugin: "io.spring.dependency-management" - apply plugin: "org.springframework.boot" + if (project.name.startsWith('nowait-app-')) { + apply plugin: "org.springframework.boot" + } repositories { mavenCentral() diff --git a/nowait-app-admin-api/build.gradle b/nowait-app-admin-api/build.gradle index 11db36d5..f6f6a509 100644 --- a/nowait-app-admin-api/build.gradle +++ b/nowait-app-admin-api/build.gradle @@ -2,12 +2,12 @@ plugins { id 'java' } -bootJar { - enabled = true -} jar { enabled = false } +bootJar { + enabled = true +} group = 'com.nowait' version = '0.0.1-SNAPSHOT' @@ -16,10 +16,9 @@ repositories { mavenCentral() } -// api-admin에서 사용하는 도메인, 인프라 모듈만 추가 dependencies { implementation project(':nowait-common') - implementation project(':nowait-infra') // aws 관련 도메인 + implementation project(':nowait-infra') implementation project(':nowait-domain:domain-admin-rdb') implementation project(':nowait-domain:domain-core-rdb') diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/controller/OrderController.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/controller/OrderController.java index 0e7dc96c..dfc03619 100644 --- a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/controller/OrderController.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/controller/OrderController.java @@ -1,15 +1,61 @@ package com.nowait.applicationadmin.order.controller; +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import com.nowait.applicationadmin.order.dto.OrderResponseDto; +import com.nowait.applicationadmin.order.dto.OrderStatusUpdateRequestDto; +import com.nowait.applicationadmin.order.dto.OrderStatusUpdateResponseDto; +import com.nowait.applicationadmin.order.service.OrderService; +import com.nowait.common.api.ApiUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @Tag(name = "Order API", description = "주문 API") @RestController -@RequestMapping("admin/orders") +@RequestMapping("/admin/orders") @RequiredArgsConstructor public class OrderController { + private final OrderService orderService; + + @GetMapping("/{storeId}") + @Operation(summary = "주점별 주문리스트 조회", description = "특정 주점에 대한 예약리스트 조회") + @ApiResponse(responseCode = "200", description = "주리스트 조회") + public ResponseEntity getOrderListByStoreId(@PathVariable Long storeId) { + List response = orderService.findAllOrders(storeId); + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + response + ) + ); + } + + @PatchMapping("/status/{orderId}") + @Operation(summary = "주문 상태 변경", description = "특정 주문의 상태를 변경.") + @ApiResponse(responseCode = "200", description = "주문 상태 변경 성공") + @ApiResponse(responseCode = "400", description = "주문을 찾을 수 없음") + public ResponseEntity updateOrderStatus( + @PathVariable Long orderId, + @RequestBody@Valid OrderStatusUpdateRequestDto requestDto + ) { + OrderStatusUpdateResponseDto response = orderService.updateOrderStatus(orderId, requestDto.getOrderStatus()); + return ResponseEntity + .status(HttpStatus.OK) + .body(ApiUtils.success(response)); + } } diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderResponseDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderResponseDto.java new file mode 100644 index 00000000..f5e93610 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderResponseDto.java @@ -0,0 +1,31 @@ +package com.nowait.applicationadmin.order.dto; + +import java.time.LocalDateTime; + +import com.nowait.domaincorerdb.order.entity.OrderStatus; +import com.nowait.domaincorerdb.order.entity.UserOrder; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class OrderResponseDto { + private Long id; + private Long tableId; + private String depositorName; + private Integer totalPrice; + private OrderStatus status; + private LocalDateTime createdAt; + + public static OrderResponseDto fromEntity(UserOrder userOrder) { + return OrderResponseDto.builder() + .id(userOrder.getId()) + .tableId(userOrder.getTableId()) + .depositorName(userOrder.getDepositorName()) + .totalPrice(userOrder.getTotalPrice()) + .status(userOrder.getStatus()) + .createdAt(userOrder.getCreatedAt()) + .build(); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderStatusUpdateRequestDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderStatusUpdateRequestDto.java new file mode 100644 index 00000000..a28f0434 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderStatusUpdateRequestDto.java @@ -0,0 +1,16 @@ +package com.nowait.applicationadmin.order.dto; + +import com.nowait.domaincorerdb.order.entity.OrderStatus; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class OrderStatusUpdateRequestDto { + @Schema(description = "주문 상태", example = "WAITING_FOR_PAYMENT", allowableValues = {"WAITING_FOR_PAYMENT", "COOKING", "COOKED"}) + @NotNull(message = "주문상태는 필수입니다") + private final OrderStatus orderStatus; +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderStatusUpdateResponseDto.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderStatusUpdateResponseDto.java new file mode 100644 index 00000000..dfcec919 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/dto/OrderStatusUpdateResponseDto.java @@ -0,0 +1,17 @@ +package com.nowait.applicationadmin.order.dto; + +import com.nowait.domaincorerdb.order.entity.OrderStatus; +import com.nowait.domaincorerdb.order.entity.UserOrder; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class OrderStatusUpdateResponseDto { + private final OrderStatus orderStatus; + + public static OrderStatusUpdateResponseDto fromEntity(UserOrder userOrder) { + return new OrderStatusUpdateResponseDto(userOrder.getStatus()); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/service/OrderService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/service/OrderService.java new file mode 100644 index 00000000..41418531 --- /dev/null +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/order/service/OrderService.java @@ -0,0 +1,36 @@ +package com.nowait.applicationadmin.order.service; + +import java.util.List; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.nowait.applicationadmin.order.dto.OrderResponseDto; +import com.nowait.applicationadmin.order.dto.OrderStatusUpdateResponseDto; +import com.nowait.domaincorerdb.order.entity.OrderStatus; +import com.nowait.domaincorerdb.order.entity.UserOrder; +import com.nowait.domaincorerdb.order.repository.OrderRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class OrderService { + private final OrderRepository orderRepository; + + @Transactional(readOnly = true) + public List findAllOrders(Long storeId) { + return orderRepository.findAllByStore_StoreId(storeId).stream() + .map(OrderResponseDto::fromEntity) + .collect(Collectors.toList()); + } + + @Transactional + public OrderStatusUpdateResponseDto updateOrderStatus(Long orderId, OrderStatus newStatus) { + UserOrder userOrder = orderRepository.findById(orderId) + .orElseThrow(() -> new IllegalArgumentException("Order not found with id: " + orderId)); + userOrder.updateStatus(newStatus); + return OrderStatusUpdateResponseDto.fromEntity(userOrder); + } +} diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/service/TokenService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/service/TokenService.java index da934d3d..86364d92 100644 --- a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/service/TokenService.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/token/service/TokenService.java @@ -23,29 +23,29 @@ public class TokenService { private final JwtUtil jwtUtil; @Transactional - public Boolean validateToken(String token, Long userId){ + public boolean validateToken(String token, Long userId){ // DB에서 해당 userId와 일치하는 리프레시토큰을 찾는다. Optional savedToken = tokenRepository.findByUserId(userId); // DB에서 userId에 대응되는 리프레시토큰 없으면, 유효하지 않음 if (savedToken.isEmpty()){ - log.info("여기에 걸렸니 ? -- 1 "); + log.debug("DB에 현재 userId에 대응되는 리프레시 토큰이 없습니다"); return false; } // 리프레시 토큰이 DB에 저장된 토큰과 일치하는지 확인 if (!savedToken.get().getRefreshToken().equals(token)){ - log.info("여기에 걸렸니 ? -- 2 "); + log.debug("DB에 저장된 리프레시 토큰와 현재 전달받은 리프레시 토큰 일치하지 않습니다"); return false; } // 리프레시 토큰의 만료여부 확인 if(jwtUtil.isExpired(token)){ - log.info("여기에 걸렸니 ? -- 3 "); + log.debug("리프레시 토큰이 만료된 토큰입니다"); return false; // 만료된 토큰은 유효하지 않음 } - log.info("여기에 걸렸니 ? -- 4 "); + log.info("리프레시 토큰이 유효한 토큰입니다"); return true; // 모든 조건 만족 시, 유효한 토큰 } diff --git a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/serivce/UserService.java b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/serivce/UserService.java index 7165ceef..669a202b 100644 --- a/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/serivce/UserService.java +++ b/nowait-app-admin-api/src/main/java/com/nowait/applicationadmin/user/serivce/UserService.java @@ -60,7 +60,13 @@ public ManagerLoginResponseDto login(ManagerLoginRequestDto managerLoginRequestD ); MemberDetails memberDetails = (MemberDetails) authentication.getPrincipal(); User user = userRepository.getReferenceById(memberDetails.getId()); - String accessToken = jwtUtil.createAccessToken("accessToken", user.getId(), String.valueOf(user.getRole()), accessTokenExpiration); + + long currentAccessTokenExpiration = accessTokenExpiration; + if (user.getRole() == com.nowait.common.enums.Role.SUPER_ADMIN) { + currentAccessTokenExpiration = 7L * 24 * 60 * 60 * 1000L; // 7일 + } + + String accessToken = jwtUtil.createAccessToken("accessToken", user.getId(), String.valueOf(user.getRole()), currentAccessTokenExpiration); return ManagerLoginResponseDto.fromEntity(user,accessToken); } } diff --git a/nowait-app-user-api/build.gradle b/nowait-app-user-api/build.gradle index 47adf831..6e3a1302 100644 --- a/nowait-app-user-api/build.gradle +++ b/nowait-app-user-api/build.gradle @@ -2,12 +2,12 @@ plugins { id 'java' } -bootJar { - enabled = true -} jar { enabled = false } +bootJar { + enabled = true +} group = 'com.nowait' version = '0.0.1-SNAPSHOT' @@ -30,7 +30,6 @@ dependencies { // SPRING SECURITY implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.boot:spring-boot-starter-test' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' // jwt implementation 'io.jsonwebtoken:jjwt-api:0.12.3' diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/service/BookmarkService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/service/BookmarkService.java index 9585d7cf..615bf00b 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/service/BookmarkService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/bookmark/service/BookmarkService.java @@ -1,6 +1,7 @@ package com.nowait.applicationuser.bookmark.service; import java.util.List; +import java.util.Objects; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -59,7 +60,7 @@ public String deleteBookmark(Long bookmarkId, CustomOAuth2User customOAuth2User) parameterValidation(bookmarkId, customOAuth2User); Bookmark bookmark = bookmarkRepository.findById(bookmarkId) .orElseThrow(() -> new EntityNotFoundException(bookmarkId + " bookmark not found.")); - if (bookmark.getUser().getId() != customOAuth2User.getUserId()) { + if (!Objects.equals(bookmark.getUser().getId(), customOAuth2User.getUserId())) { throw new IllegalArgumentException("you can only delete your own bookmark"); } bookmarkRepository.delete(bookmark); diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/oauth2/OAuth2LoginSuccessHandler.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/oauth2/OAuth2LoginSuccessHandler.java index ab59aa54..0417d1c9 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/oauth2/OAuth2LoginSuccessHandler.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/oauth/oauth2/OAuth2LoginSuccessHandler.java @@ -18,6 +18,7 @@ import com.nowait.domaincorerdb.user.entity.User; import com.nowait.domainuserrdb.oauth.dto.CustomOAuth2User; +import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; @@ -39,42 +40,32 @@ public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHan public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { - // 1. CustomOAuth2UserService에서 설정한 OAuth2User 정보 가져오기 - CustomOAuth2User customUserDetails = (CustomOAuth2User)authentication.getPrincipal(); - + CustomOAuth2User customUserDetails = (CustomOAuth2User) authentication.getPrincipal(); User user = customUserDetails.getUser(); Long userId = customUserDetails.getUserId(); - String email = customUserDetails.getName(); - - Collection authorities = authentication.getAuthorities(); - Iterator iterator = authorities.iterator(); - GrantedAuthority auth = iterator.next(); - - String role = auth.getAuthority(); + String role = authentication.getAuthorities().iterator().next().getAuthority(); - log.info("user, userId, email, role :: {} {} {} {}", user, userId, email, role); + // JWT 발급 + String accessToken = jwtUtil.createAccessToken("accessToken", userId, role, 30 * 60 * 1000L); // 30분 + String refreshToken = jwtUtil.createRefreshToken("refreshToken", userId, 30L * 24 * 60 * 60 * 1000L); // 30일 - // 2. 1)의 사용자 정보를 담아, accessToken과 refreshToken 발행 - String accessToken = jwtUtil.createAccessToken("accessToken", userId, role, 30 * 60 * 1000L); // 유효기간 30분 - String refreshToken = jwtUtil.createRefreshToken("refreshToken", userId, - 30 * 24 * 60 * 60 * 1000L); // 유효기간 30일 - - // 3. refreshToken을 DB에 저장 + // 1. refreshToken을 DB에 저장 Token refreshTokenEntity = Token.toEntity(user, refreshToken, LocalDateTime.now().plusDays(30)); tokenRepository.save(refreshTokenEntity); - // 4. JSON 응답으로, accessToken과 refreshToken 을 반환해준다. - response.setContentType("application/json"); - response.setCharacterEncoding("utf-8"); + // 2. refreshToken을 HttpOnly 쿠키로 설정 + Cookie refreshTokenCookie = new Cookie("refreshToken", refreshToken); + refreshTokenCookie.setHttpOnly(true); // JS 접근 불가 + refreshTokenCookie.setSecure(false); // 운영환경 https라면 true로 변경 필요 + refreshTokenCookie.setPath("/"); + refreshTokenCookie.setMaxAge(30 * 24 * 60 * 60); // 30일 + response.addCookie(refreshTokenCookie); + response.addHeader("Set-Cookie", response.getHeader("Set-Cookie") + "; SameSite=Lax"); + - ObjectMapper objectMapper = new ObjectMapper(); // 객체 -> json 문자열로 변환 - String body = objectMapper.writeValueAsString( - Map.of( - "accessToken", accessToken, - "refreshToken", refreshToken - ) - ); - response.getWriter().write(body); + // 3. 프론트엔드로 리다이렉트 (accessToken만 쿼리로 전달) + String targetUrl = "http://localhost:5173/login/success?accessToken=" + accessToken; + response.sendRedirect(targetUrl); } } diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/controller/OrderController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/controller/OrderController.java index 87cb3fe0..43be357d 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/controller/OrderController.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/controller/OrderController.java @@ -57,9 +57,7 @@ public ResponseEntity getOrderItems( @PathVariable Long tableId, HttpSession session ) { - // 세션ID 추출 (Spring이 세션 자동 관리) String sessionId = session.getId(); - List orderItems = orderService.getOrderItems(storeId, tableId, sessionId); return ResponseEntity. status(HttpStatus.OK) diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateRequestDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateRequestDto.java index 8823d0e4..89de6631 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateRequestDto.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateRequestDto.java @@ -3,6 +3,9 @@ import java.util.List; import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; import jakarta.validation.constraints.Size; import lombok.AllArgsConstructor; import lombok.Builder; @@ -15,6 +18,10 @@ public class OrderCreateRequestDto { @NotBlank(message = "주문자 이름은 필수입니다") @Size(max = 10, message = "주문자 이름은 10자 이하여야 합니다") private final String depositorName; // 예약자 이름 + @NotEmpty(message = "주문 내역은 필수입니다") private final List items; // 장바구니 항목 리스트 + @NotNull(message = "주문금액은 필수입니다") + @Positive(message = "주문금액은 양수여야 합니다") + private final int totalPrice; } diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateResponseDto.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateResponseDto.java index f959a3ec..a11a863d 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateResponseDto.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/dto/OrderCreateResponseDto.java @@ -3,6 +3,7 @@ import java.util.List; import com.nowait.domaincorerdb.order.entity.UserOrder; +import com.nowait.domaincorerdb.order.entity.OrderStatus; import lombok.AllArgsConstructor; import lombok.Builder; @@ -18,6 +19,8 @@ public class OrderCreateResponseDto { private String sessionId; private String depositorName; private List orderItems; // 주문 항목 목록 + private OrderStatus status; + private Integer totalPrice; public static OrderCreateResponseDto fromEntity(UserOrder order) { return OrderCreateResponseDto.builder() @@ -27,6 +30,8 @@ public static OrderCreateResponseDto fromEntity(UserOrder order) { .sessionId(order.getSessionId()) .depositorName(order.getDepositorName()) .orderItems(List.of()) + .status(order.getStatus()) + .totalPrice(order.getTotalPrice()) .build(); } } diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/service/OrderService.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/service/OrderService.java index 88a9d5d1..1f0ed53c 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/service/OrderService.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/order/service/OrderService.java @@ -18,6 +18,7 @@ import com.nowait.domaincorerdb.menu.entity.Menu; import com.nowait.domaincorerdb.menu.repository.MenuRepository; import com.nowait.domaincorerdb.order.entity.OrderItem; +import com.nowait.domaincorerdb.order.entity.OrderStatus; import com.nowait.domaincorerdb.order.entity.UserOrder; import com.nowait.domaincorerdb.order.exception.DuplicateOrderException; import com.nowait.domaincorerdb.order.exception.OrderItemsEmptyException; @@ -56,6 +57,9 @@ public OrderCreateResponseDto createOrder(Long storeId, Long tableId, .signature(signature) // signature 저장 .sessionId(sessionId) // sessionId 저장 .depositorName(orderCreateRequestDto.getDepositorName()) + .status(OrderStatus.WAITING_FOR_PAYMENT) + .totalPrice(orderCreateRequestDto.getTotalPrice()) + .build(); UserOrder savedOrder = orderRepository.save(order); diff --git a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/controller/TokenController.java b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/controller/TokenController.java index 8909115d..9a4be370 100644 --- a/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/controller/TokenController.java +++ b/nowait-app-user-api/src/main/java/com/nowait/applicationuser/token/controller/TokenController.java @@ -40,10 +40,15 @@ public ResponseEntity refreshToken(@RequestBody RefreshTokenRequest request){ Long userId = jwtUtil.getUserId(refreshToken); String role = jwtUtil.getRole(refreshToken); + long currentAccessTokenExpiration = accessTokenExpiration; + if (role.equals("SUPER_ADMIN")) { + currentAccessTokenExpiration = 7L * 24 * 60 * 60 * 1000L; // 7일 + } + // 리프레시 토큰 유효성 검증 if (tokenService.validateToken(refreshToken, userId)){ // 유효한 토큰이라면, 새로운 accessToken, refreshToken 생성 - String newAccessToken = jwtUtil.createAccessToken("accessToken", userId, role, accessTokenExpiration); + String newAccessToken = jwtUtil.createAccessToken("accessToken", userId, role, currentAccessTokenExpiration); String newRefreshToken = jwtUtil.createRefreshToken("refreshToken", userId, refreshTokenExpiration); // DB에 새로운 refreshToken으로 교체 diff --git a/nowait-common/build.gradle b/nowait-common/build.gradle index 41106356..c2f76e1b 100644 --- a/nowait-common/build.gradle +++ b/nowait-common/build.gradle @@ -1,9 +1,6 @@ jar { enabled = true } -bootJar { - enabled = false -} java { toolchain { diff --git a/nowait-domain/build.gradle b/nowait-domain/build.gradle index ebc5b4ae..9ce9a13f 100644 --- a/nowait-domain/build.gradle +++ b/nowait-domain/build.gradle @@ -5,9 +5,6 @@ plugins { jar { enabled = true } -bootJar { - enabled = false -} group = 'com.nowaiting' @@ -32,10 +29,11 @@ dependencies { api 'jakarta.persistence:jakarta.persistence-api:3.1.0' // SPRING SECURITY api 'org.springframework.boot:spring-boot-starter-security' - api 'org.springframework.boot:spring-boot-starter-test' // Lombok (optional) compileOnly 'org.projectlombok:lombok:1.18.26' annotationProcessor 'org.projectlombok:lombok:1.18.26' + // SWAGGER + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' // Test testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' diff --git a/nowait-domain/domain-admin-rdb/build.gradle b/nowait-domain/domain-admin-rdb/build.gradle index 197c323f..6b1b69c7 100644 --- a/nowait-domain/domain-admin-rdb/build.gradle +++ b/nowait-domain/domain-admin-rdb/build.gradle @@ -5,9 +5,6 @@ plugins { jar { enabled = true } -bootJar { - enabled = false -} group = 'com.nowaiting' @@ -35,4 +32,7 @@ dependencies { testImplementation 'org.junit.jupiter:junit-jupiter' testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine' + + // SWAGGER + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' } diff --git a/nowait-domain/domain-core-rdb/build.gradle b/nowait-domain/domain-core-rdb/build.gradle index 7ea55f16..01bc023b 100644 --- a/nowait-domain/domain-core-rdb/build.gradle +++ b/nowait-domain/domain-core-rdb/build.gradle @@ -5,9 +5,6 @@ plugins { jar { enabled = true } -bootJar { - enabled = false -} group = 'com.nowait' @@ -32,9 +29,11 @@ dependencies { // SPRING SECURITY implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.boot:spring-boot-starter-test' - implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation 'org.springframework.boot:spring-boot-starter-validation' + // SWAGGER + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + compileOnly 'org.projectlombok:lombok:1.18.26' annotationProcessor 'org.projectlombok:lombok:1.18.26' diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/OrderStatus.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/OrderStatus.java new file mode 100644 index 00000000..98b50744 --- /dev/null +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/OrderStatus.java @@ -0,0 +1,22 @@ +package com.nowait.domaincorerdb.order.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Schema(description = "주문 상태 Enum") +public enum OrderStatus { + @Schema(description = "입금대기") + WAITING_FOR_PAYMENT("입금대기"), + + @Schema(description = "조리중") + COOKING("조리중"), + + @Schema(description = "조리완료") + COOKED("조리완료"); + + private final String description; +} + diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/UserOrder.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/UserOrder.java index 3f2ae656..5dbd0295 100644 --- a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/UserOrder.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/entity/UserOrder.java @@ -9,6 +9,8 @@ import jakarta.persistence.CascadeType; import jakarta.persistence.Column; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.FetchType; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; @@ -43,10 +45,22 @@ public class UserOrder extends BaseTimeEntity { private Store store; @OneToMany(mappedBy = "userOrder", cascade = CascadeType.ALL, orphanRemoval = true) + @Builder.Default private List orderItems = new ArrayList<>(); private String sessionId; @Column(length = 10) // 예약자 이름 길이 제한 private String depositorName; + @Builder.Default + @Enumerated(value = EnumType.STRING) + private OrderStatus status = OrderStatus.WAITING_FOR_PAYMENT; + + @Column(nullable = false) + private Integer totalPrice; + + public void updateStatus(OrderStatus newStatus) { + this.status = newStatus; + } + } diff --git a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderRepository.java b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderRepository.java index b07c96dd..96db42c4 100644 --- a/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderRepository.java +++ b/nowait-domain/domain-core-rdb/src/main/java/com/nowait/domaincorerdb/order/repository/OrderRepository.java @@ -12,6 +12,7 @@ public interface OrderRepository extends JpaRepository { boolean existsBySignatureAndCreatedAtAfter(String signature, LocalDateTime createdAt); List findByStore_StoreIdAndTableIdAndSessionId(Long storeId, Long tableId, String sessionId); + List findAllByStore_StoreId(Long storeId); } diff --git a/nowait-domain/domain-user-rdb/build.gradle b/nowait-domain/domain-user-rdb/build.gradle index 09aa35f3..5a14ae53 100644 --- a/nowait-domain/domain-user-rdb/build.gradle +++ b/nowait-domain/domain-user-rdb/build.gradle @@ -5,9 +5,6 @@ plugins { jar { enabled = true } -bootJar { - enabled = false -} group = 'com.nowaiting' @@ -35,6 +32,9 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' implementation 'jakarta.servlet:jakarta.servlet-api:6.0.0' + // SWAGGER + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.6.0' + compileOnly 'org.projectlombok:lombok:1.18.26' annotationProcessor 'org.projectlombok:lombok:1.18.26' diff --git a/nowait-infra/build.gradle b/nowait-infra/build.gradle index aa478c84..5cbc1f17 100644 --- a/nowait-infra/build.gradle +++ b/nowait-infra/build.gradle @@ -5,9 +5,6 @@ plugins { jar { enabled = true } -bootJar { - enabled = false -} group = 'com.nowaiting' diff --git a/security-front/src/main/java/com/nowait/auth/config/CorsConfig.java b/security-front/src/main/java/com/nowait/auth/config/CorsConfig.java new file mode 100644 index 00000000..34b293b5 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/config/CorsConfig.java @@ -0,0 +1,28 @@ +package com.nowait.auth.config; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowCredentials(true); // 쿠키나 인증헤더 자격증명 허용 + config.setAllowedOrigins(List.of("http://localhost:3000", "http://localhost:8083")); // 허용할 출처 설정 + config.setAllowedMethods(List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS")); // 메서드 허용 + config.setAllowedHeaders(List.of("*")); //클라이언트가 보낼 수 있는 헤더 + config.setExposedHeaders(List.of("Authorization")); //클라이언트(브라우저)가 접근할 수 있는 헤더 지정 + // config.setAllowCredentials(true); // 쿠키 포함 허용 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); //** 뜻은 모든 URL 경로에 적용한다는 의미 + return source; + } +} diff --git a/security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java b/security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java new file mode 100644 index 00000000..c4ebb970 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java @@ -0,0 +1,93 @@ +package com.nowait.auth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +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; +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; +import org.springframework.web.cors.CorsConfigurationSource; + +import com.nowait.auth.jwt.JwtAuthorizationFilter; +import com.nowait.auth.jwt.JwtUtil; +import com.nowait.auth.oauth2.CustomOAuth2UserService; +import com.nowait.auth.oauth2.OAuth2LoginSuccessHandler; +import com.nowait.auth.service.CustomUserDetailService; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity // security 활성화 어노테이션 +@RequiredArgsConstructor +public class SecurityConfig { + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2LoginSuccessHandler OAuth2LoginSuccessHandler; + private final JwtUtil jwtUtil; + private final CustomUserDetailService userDetailsService; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // CSRF 방어 기능 비활성화 (jwt 토큰을 사용할 것이기에 필요없음) + .csrf(AbstractHttpConfigurer::disable) + // 시큐리티 폼 로그인 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + // HTTP Basic 인증 비활성화 + .httpBasic(AbstractHttpConfigurer::disable) + // oauth2 로그인 + // - userInfoEndPoint에서 사용자 정보 불러오고, + // - successHandler에서 로그인 성공 시 JWT 생성 및 반환로직 + .oauth2Login(oauth2 -> + oauth2.userInfoEndpoint(userInfoEndpoint -> + userInfoEndpoint.userService(customOAuth2UserService) + ).successHandler(OAuth2LoginSuccessHandler) + ) + // 세션 사용하지 않음 + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/oauth2/authorization/kakao", // 카카오 로그인 요청 + "/login/oauth2/code/**", // 카카오 인증 콜백 + "/api/refresh-token", // refresh token (토큰 갱신) + "/orders/**", // 주문 관련 API + "/v1/menus/**", + "/v1/stores/**", + "/api/users/signup", + "/api/users/login", + "/swagger-ui/**", + "/v3/api-docs/**", + "/v3/api-docs.json", + "/api-docs/**", + "/swagger-resources/**", + "/webjars/**", + "/demo-ui.html" + ) + .permitAll() + .anyRequest().authenticated() // 그외 요청은 허가된 사람만 인가 + ) + // JWTFiler + .addFilterBefore(new JwtAuthorizationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); + authenticationProvider.setUserDetailsService(userDetailsService); + authenticationProvider.setPasswordEncoder(passwordEncoder()); + return authenticationProvider; + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java b/security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java new file mode 100644 index 00000000..61432393 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java @@ -0,0 +1,55 @@ +package com.nowait.auth.dto; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import com.nowait.user.entity.User; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CustomOAuth2User implements OAuth2User { + private final User user; + + @Override + public Map getAttributes() { + return Map.of("email", user.getEmail(), "nickname", user.getNickname()); + } + + // 사용자가 가지는 권한 설정 + @Override + public Collection getAuthorities() { + Collection authorities = new ArrayList<>(); + + authorities.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return user.getRole().getName(); // 유저의 권한 리턴 + } + }); + + return authorities; + } + + @Override + public String getName() { + return user.getEmail(); + } + + public User getUser() { + return user; + } + + public Long getUserId() { + return user.getId(); + } + + public String getNickname() { + return user.getNickname(); + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/dto/KaKaoResponse.java b/security-front/src/main/java/com/nowait/auth/dto/KaKaoResponse.java new file mode 100644 index 00000000..e745bd26 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/dto/KaKaoResponse.java @@ -0,0 +1,40 @@ +package com.nowait.auth.dto; + +import java.util.Map; + +import lombok.RequiredArgsConstructor; + +// 카카오 OAuth2 응답에서 필요한 정보를 추출하는 역할 +@RequiredArgsConstructor +public class KaKaoResponse implements OAuth2Response { + private final Map attributes; + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getProviderId() { + return attributes.get("id").toString(); + } + + @Override + public String getEmail() { + Map kakaoAccount = (Map)attributes.get("kakao_account"); + return kakaoAccount.get("email").toString(); + } + + @Override + public String getNickName() { + Map properties = (Map)attributes.get("properties"); + return properties.get("nickname").toString(); + } + + @Override + public String getProfileImage() { + Map properties = (Map)attributes.get("properties"); + return properties.get("profile_image").toString(); + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/dto/OAuth2Response.java b/security-front/src/main/java/com/nowait/auth/dto/OAuth2Response.java new file mode 100644 index 00000000..de987480 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/dto/OAuth2Response.java @@ -0,0 +1,19 @@ +package com.nowait.auth.dto; + +public interface OAuth2Response { + // 제공자 (ex. naver, kakao) + String getProvider(); + + // 제공자에서 발급해주는 아이디 (번호) + String getProviderId(); + + // 아래 이메일, 닉네임, 프로필이미지는 내가 카카오 developers에서 발급받겠다고 신청한 정보들이다 + // 이메일 + String getEmail(); + + // 닉네임 + String getNickName(); + + // 프로필이미지 + String getProfileImage(); +} diff --git a/security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java b/security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java new file mode 100644 index 00000000..dcbbd985 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java @@ -0,0 +1,98 @@ +package com.nowait.auth.jwt; + +import java.io.IOException; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.nowait.auth.dto.CustomOAuth2User; +import com.nowait.common.enums.Role; +import com.nowait.common.enums.SocialType; +import com.nowait.user.entity.User; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// JWT 검증 필터 +// 1. 헤더에서 accessToken 추출, 2. 토큰 검증, 3. 유효하면 사용자정보를 SecurityContextHolder에 세팅 +// 그러면, 이후 컨트롤러에서 @AuthenticationPrincipal에서 저장했던 사용자 정보를 꺼내쓸 수 있음 +@RequiredArgsConstructor +@Slf4j +public class JwtAuthorizationFilter extends OncePerRequestFilter { + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String header = request.getHeader("Authorization"); + + // 인증헤더 Bearer가 없다면, 다음 필터로 넘김 + if (header == null || !header.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + + log.debug("JwtAuthorizationFilter: Authorization 헤더가 없거나 Bearer 토큰 형식이 아님. JWT 인증 필터를 건너뜁니다. [header={}] ", header); + return; + } + + log.info("header :: {}, header.substring(7) :: {}", header, header.substring(7)); + String accessToken = header.substring(7); + + // 토큰 만료 여부 확인, 만료 시 다음 필터로 넘기지 않음 + try { + jwtUtil.isExpired(accessToken); + } catch (ExpiredJwtException e) { + + // response status code + msg + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().print("access token expired"); + + log.warn("JwtAuthorizationFilter: 만료된 AccessToken입니다. 토큰 인증 거부, URI: {}", request.getRequestURI()); + return; + } + + // 토큰이 accessToken 종류인지 확인 + String tokenCategory = jwtUtil.getTokenCategory(accessToken); + + if (!tokenCategory.equals("accessToken")) { + //response status code + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().print("invalid access token"); + + log.warn("JwtAuthorizationFilter: 잘못된 토큰 유형(accessToken 아님)으로 인증 요청. URI: {}, tokenCategory: {}", request.getRequestURI(), tokenCategory); + return; + } + + // userId와 role 값 추출 + Long userId = jwtUtil.getUserId(accessToken); + String roleString = jwtUtil.getRole(accessToken); + if (userId == null || roleString == null) { + log.warn("JwtAuthorizationFilter: JWT에서 userId 또는 role 추출 실패. 토큰: {}", accessToken); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().print("invalid token"); + return; + } + User user = User.createUserWithId(userId, "sampleEmail", "sampleNickname", "sampleProfileImg" + , SocialType.KAKAO, Role.fromString(roleString)); + + CustomOAuth2User customOAuth2User = new CustomOAuth2User(user); + + // 스프링 시큐리티 인증 토큰 생성 + UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken( + customOAuth2User, null, customOAuth2User.getAuthorities()); + + // 생성한 인증 정보를 SecurityContext에 설정 + SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); + + log.info("JwtAuthorizationFilter: 인증 성공. userId={}, role={}, URI={}", userId, roleString, request.getRequestURI()); + + filterChain.doFilter(request, response); + + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/jwt/JwtUtil.java b/security-front/src/main/java/com/nowait/auth/jwt/JwtUtil.java new file mode 100644 index 00000000..fd0620e9 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/jwt/JwtUtil.java @@ -0,0 +1,76 @@ +package com.nowait.auth.jwt; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Jwts; + +@Component +public class JwtUtil { + private final SecretKey secretKey; + + // 시크릿 키를 암호화하여, 키 생성 + public JwtUtil(@Value("${jwt.secret}") String secret) { + this.secretKey = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm() + ); + } + + public String createAccessToken(String tokenCategory, Long userId, String role, Long expiredMs) { + return Jwts.builder() + .claim("tokenCategory", tokenCategory) // accessToken + .claim("userId", userId) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } + + public String createRefreshToken(String tokenCategory, Long userId, Long expiredMs) { + return Jwts.builder() + .claim("tokenCategory", tokenCategory) // refreshToken + .claim("userId", userId) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } + + public String getTokenCategory(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseClaimsJws(token) + .getBody() + .get("tokenCategory", String.class); + } + + public String getRole(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseClaimsJws(token) + .getBody() + .get("role", String.class); + } + + public Long getUserId(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseClaimsJws(token) + .getBody() + .get("userId", Long.class); + } + + public Boolean isExpired(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseSignedClaims(token) + .getPayload() + .getExpiration() + .before(new Date()); + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/oauth2/CustomOAuth2UserService.java b/security-front/src/main/java/com/nowait/auth/oauth2/CustomOAuth2UserService.java new file mode 100644 index 00000000..b51577f7 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,70 @@ +package com.nowait.auth.oauth2; + +import java.util.Optional; + +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; + +import com.nowait.auth.dto.CustomOAuth2User; +import com.nowait.auth.dto.KaKaoResponse; +import com.nowait.auth.dto.OAuth2Response; +import com.nowait.common.enums.Role; +import com.nowait.common.enums.SocialType; +import com.nowait.user.entity.User; +import com.nowait.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// OAuth2 제공자(카카오)로부터 제공받은 사용자 정보를, 우리 서비스에 맞게 가공, 변환 +@Service +@RequiredArgsConstructor +@Slf4j +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + private final UserRepository userRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + log.info("CustomOAuth2UserService :: {}", oAuth2User); + log.info("oAuthUser.getAttributes :: {}", oAuth2User.getAttributes()); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + OAuth2Response oAuth2Response = null; + + if (registrationId.equals("kakao")) { + oAuth2Response = new KaKaoResponse(oAuth2User.getAttributes()); + } else { + throw new OAuth2AuthenticationException("지원하지 않는 OAuth2 Provider 입니다."); + } + + // DB에 유저가 있는지 판단 + Optional foundUser = userRepository.findByEmail(oAuth2Response.getEmail()); + + // DB에 유저 없으면 - 회원가입 + if (foundUser.isEmpty()) { + + User user = User.builder() + .email(oAuth2Response.getEmail()) + .nickname(oAuth2Response.getNickName()) + .profileImage(oAuth2Response.getProfileImage()) + .socialType(SocialType.KAKAO) + .role(Role.USER) // 일반 유저 설정 + .build(); + + userRepository.save(user); + + return new CustomOAuth2User(user); + } else { + // DB에 유저 존재하면 - 로그인 진행 (이때 로그인 처리는 안하고, OAuth2LoginSuccessHandler에서 담당함) + User user = foundUser.get(); + + return new CustomOAuth2User(user); + } + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/oauth2/OAuth2LoginSuccessHandler.java b/security-front/src/main/java/com/nowait/auth/oauth2/OAuth2LoginSuccessHandler.java new file mode 100644 index 00000000..9364e1cc --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/oauth2/OAuth2LoginSuccessHandler.java @@ -0,0 +1,80 @@ +package com.nowait.auth.oauth2; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Iterator; +import java.util.Map; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.example.domaintoken.entity.Token; +import com.example.domaintoken.repository.TokenRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nowait.auth.dto.CustomOAuth2User; +import com.nowait.auth.jwt.JwtUtil; +import com.nowait.user.entity.User; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// 카카오 로그인 성공 시, 콜백 핸들러 +// 1. JWT 토큰 발급 +// - 이때, JWT payload는 보안상 최소한의 정보(userId, role)만 담겠다 +// 2. refreshToken만 DB에 저장 +// 3. JSON 응답으로, accessToken과 refreshToken 을 반환해준다. +@Component +@RequiredArgsConstructor +@Slf4j +public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private final JwtUtil jwtUtil; + private final TokenRepository tokenRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + // 1. CustomOAuth2UserService에서 설정한 OAuth2User 정보 가져오기 + CustomOAuth2User customUserDetails = (CustomOAuth2User)authentication.getPrincipal(); + + User user = customUserDetails.getUser(); + Long userId = customUserDetails.getUserId(); + String email = customUserDetails.getName(); + + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + + String role = auth.getAuthority(); + + log.info("user, userId, email, role :: {} {} {} {}", user, userId, email, role); + + // 2. 1)의 사용자 정보를 담아, accessToken과 refreshToken 발행 + String accessToken = jwtUtil.createAccessToken("accessToken", userId, role, 60 * 60 * 1000L); // 유효기간 1시간 + String refreshToken = jwtUtil.createRefreshToken("refreshToken", userId, + 30 * 24 * 60 * 60 * 1000L); // 유효기간 30일 + + // 3. refreshToken을 DB에 저장 + Token refreshTokenEntity = Token.toEntity(user, refreshToken, LocalDateTime.now().plusDays(30)); + tokenRepository.save(refreshTokenEntity); + + // 4. JSON 응답으로, accessToken과 refreshToken 을 반환해준다. + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + + ObjectMapper objectMapper = new ObjectMapper(); // 객체 -> json 문자열로 변환 + String body = objectMapper.writeValueAsString( + Map.of( + "accessToken", accessToken, + "refreshToken", refreshToken + ) + ); + response.getWriter().write(body); + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/service/CustomUserDetailService.java b/security-front/src/main/java/com/nowait/auth/service/CustomUserDetailService.java new file mode 100644 index 00000000..4476c9c4 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/service/CustomUserDetailService.java @@ -0,0 +1,30 @@ +package com.nowait.auth.service; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; + +import com.nowait.user.entity.MemberDetails; +import com.nowait.user.entity.User; +import com.nowait.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CustomUserDetailService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email).orElseThrow(); + return MemberDetails.create(user); + } + + public UserDetails loadUserById(Long id) throws UsernameNotFoundException { + User member = userRepository.findById(id).orElseThrow(); + return MemberDetails.create(member); + } +} diff --git a/security-front/src/main/java/com/nowait/exception/BusinessException.java b/security-front/src/main/java/com/nowait/exception/BusinessException.java new file mode 100644 index 00000000..60eec165 --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/BusinessException.java @@ -0,0 +1,14 @@ +package com.nowait.exception; + +public abstract class BusinessException extends RuntimeException { + private final ErrorMessage errorMessage; + + protected BusinessException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public String getCode() { + return errorMessage.getCode(); + } +} diff --git a/security-front/src/main/java/com/nowait/exception/ErrorMessage.java b/security-front/src/main/java/com/nowait/exception/ErrorMessage.java new file mode 100644 index 00000000..c365a92e --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/ErrorMessage.java @@ -0,0 +1,22 @@ +package com.nowait.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorMessage { + // global + INVALID_INPUT_VALUE("입력값이 올바르지 않습니다.", "global001"), + + // auth + UNAUTHORIZED("권한이 없습니다", "auth001"), + + // token + REFRESH_TOKEN_NOT_FOUND("기존 리프레시 토큰을 찾을 수 없습니다.", "token001"), + DOES_NOT_MATCH_REFRESH_TOKEN("기존 리프레시 토큰이 일치하지 않습니다.", "token002"); + + private final String message; + private final String code; + +} diff --git a/security-front/src/main/java/com/nowait/exception/ErrorResponse.java b/security-front/src/main/java/com/nowait/exception/ErrorResponse.java new file mode 100644 index 00000000..a15935e8 --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/ErrorResponse.java @@ -0,0 +1,26 @@ +package com.nowait.exception; + +import java.util.HashMap; +import java.util.Map; + +import lombok.Getter; + +@Getter +public class ErrorResponse { + private final String message; + private final String code; + private final Map errors; + + public ErrorResponse(String message, String code) { + this.message = message; + this.code = code; + errors = new HashMap<>(); + } + + public ErrorResponse(String message, String code, Map errors) { + this.message = message; + this.code = code; + this.errors = errors; + } + +} diff --git a/security-front/src/main/java/com/nowait/exception/GlobalExceptionHandler.java b/security-front/src/main/java/com/nowait/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..b9f7c1e2 --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/GlobalExceptionHandler.java @@ -0,0 +1,108 @@ +package com.nowait.exception; + +import static com.nowait.exception.ErrorMessage.*; +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestValueException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MultipartException; + +import io.swagger.v3.oas.annotations.Hidden; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Hidden +@RestControllerAdvice +public class GlobalExceptionHandler { + + // OAUTH 인증 실패 에러처리 메서드 + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(OAuth2AuthenticationException.class) + public ErrorResponse handlerOAuth2AuthenticationException(OAuth2AuthenticationException e) { + log.error("handleOAuth2AuthenticationException", e); + + return new ErrorResponse("OAuth 인증 실패 : " + e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(BusinessException.class) + public ErrorResponse handleBusinessException(BusinessException e) { + log.error("handleBusinessException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.error("handleMethodArgumentNotValidException", e); + Map errors = getErrors(e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode(), errors); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(HttpMessageNotReadableException.class) + public ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("handleHttpMessageNotReadableException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(IllegalArgumentException.class) + public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e) { + log.error("handleIllegalArgumentException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MissingRequestValueException.class) + public ErrorResponse handleMissingRequestValueException(MissingRequestValueException e) { + log.error("handleMissingRequestValueExceptionException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = UNAUTHORIZED) + @ExceptionHandler(UnauthorizedException.class) + public ErrorResponse handleUnauthorizedException(UnauthorizedException e) { + log.error("handleUnauthorizedExceptionException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = NOT_FOUND) + @ExceptionHandler(ResourceNotFoundException.class) + public ErrorResponse handleResourceNotFoundException(ResourceNotFoundException e) { + log.error("handleResourceNotFoundExceptionException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MultipartException.class) + public ErrorResponse handleMultipartException(MultipartException e) { + log.error("handleMultipartException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + + private static Map getErrors(MethodArgumentNotValidException e) { + return e.getBindingResult() + .getAllErrors() + .stream() + .filter(ObjectError.class::isInstance) + .collect(Collectors.toMap( + error -> error instanceof FieldError ? ((FieldError)error).getField() : error.getObjectName(), + ObjectError::getDefaultMessage, + (msg1, msg2) -> msg1 + ";" + msg2 + )); + } + +} diff --git a/security-front/src/main/java/com/nowait/exception/RefreshTokenNotFoundException.java b/security-front/src/main/java/com/nowait/exception/RefreshTokenNotFoundException.java new file mode 100644 index 00000000..5365ef52 --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/RefreshTokenNotFoundException.java @@ -0,0 +1,9 @@ +package com.nowait.exception; + +public class RefreshTokenNotFoundException extends ResourceNotFoundException { + + public RefreshTokenNotFoundException() { + super(ErrorMessage.REFRESH_TOKEN_NOT_FOUND); + } + +} diff --git a/security-front/src/main/java/com/nowait/exception/ResourceNotFoundException.java b/security-front/src/main/java/com/nowait/exception/ResourceNotFoundException.java new file mode 100644 index 00000000..ae4e4b7b --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/ResourceNotFoundException.java @@ -0,0 +1,14 @@ +package com.nowait.exception; + +public abstract class ResourceNotFoundException extends RuntimeException { + private final ErrorMessage errorMessage; + + protected ResourceNotFoundException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public String getCode() { + return errorMessage.getCode(); + } +} diff --git a/security-front/src/main/java/com/nowait/exception/TokenBadRequestException.java b/security-front/src/main/java/com/nowait/exception/TokenBadRequestException.java new file mode 100644 index 00000000..504852ee --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/TokenBadRequestException.java @@ -0,0 +1,8 @@ +package com.nowait.exception; + +public class TokenBadRequestException extends BusinessException { + public TokenBadRequestException() { + super(ErrorMessage.DOES_NOT_MATCH_REFRESH_TOKEN); + } + +} diff --git a/security-front/src/main/java/com/nowait/exception/UnauthorizedException.java b/security-front/src/main/java/com/nowait/exception/UnauthorizedException.java new file mode 100644 index 00000000..65a8fd0a --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/UnauthorizedException.java @@ -0,0 +1,19 @@ +package com.nowait.exception; + +public class UnauthorizedException extends RuntimeException { + private final ErrorMessage errorMessage; + + public UnauthorizedException() { + super(ErrorMessage.UNAUTHORIZED.getMessage()); + this.errorMessage = ErrorMessage.UNAUTHORIZED; + } + + public UnauthorizedException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public String getCode() { + return errorMessage.getCode(); + } +}