From 08d31e95bd5205be468b2237f24eea8813d306f4 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Mon, 22 Jun 2026 21:51:41 +0900 Subject: [PATCH 01/19] =?UTF-8?q?[FEAT/#359]=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=A0=84=EC=9A=A9=20=EB=B0=B1=EC=98=A4=ED=94=BC=EC=8A=A4=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/test/resources/application-test.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 8a3197b2..08ee6c21 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -17,10 +17,15 @@ spring: jwt: header: Authorization prefix: Bearer - secret: dummy-secret-key-for-testing + secret: S3csfifR3TrgwiKeyM2023WClokeyAppWIFNEGIBKWMGJ access-valid-seconds: 3600 + backoffice-access-valid-seconds: 1800 refresh-valid-seconds: 1209600 +backoffice: + bootstrap: + enabled: false + assu: security: school-crypto: From 8175185467cfc08d1acdb339a30ec9b7e5c271bc Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 01:31:45 +0900 Subject: [PATCH 02/19] =?UTF-8?q?[FEAT/#359]=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EB=B6=84=EA=B8=B0=20=EB=AA=85=EC=8B=9C=ED=95=98=EA=B8=B0=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=EC=84=9C=20=EC=BB=A8=ED=8A=B8=EB=A1=A4?= =?UTF-8?q?=EB=9F=AC=EB=A7=88=EB=8B=A4=20PreAuthorize=20=EC=96=B4=EB=85=B8?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EC=85=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/controller/AdminController.java | 2 + .../controller/StudentAdminController.java | 2 + .../controller/AppReviewController.java | 2 + .../controller/CertificationController.java | 2 + .../GroupCertificationController.java | 2 + .../chat/controller/ChatController.java | 2 + .../controller/DeviceTokenController.java | 2 + .../inquiry/controller/InquiryController.java | 29 +------ .../domain/map/controller/MapController.java | 2 + .../member/controller/MemberController.java | 2 + .../controller/NotificationController.java | 2 + .../partner/controller/PartnerController.java | 2 + .../controller/PartnershipController.java | 81 +++++++++++-------- .../qr/controller/RedirectController.java | 2 + .../qr/controller/TemporaryQrController.java | 2 + .../report/controller/ReportController.java | 2 + .../review/controller/ReviewController.java | 7 ++ .../store/controller/StoreController.java | 38 ++++----- .../student/controller/StudentController.java | 35 +++----- .../controller/SuggestionController.java | 5 ++ 20 files changed, 120 insertions(+), 103 deletions(-) diff --git a/src/main/java/com/assu/server/domain/admin/controller/AdminController.java b/src/main/java/com/assu/server/domain/admin/controller/AdminController.java index e42c0b84..bc462d93 100644 --- a/src/main/java/com/assu/server/domain/admin/controller/AdminController.java +++ b/src/main/java/com/assu/server/domain/admin/controller/AdminController.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -17,6 +18,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/admin") +@PreAuthorize("hasRole('ADMIN')") public class AdminController { private final AdminService adminService; diff --git a/src/main/java/com/assu/server/domain/admin/controller/StudentAdminController.java b/src/main/java/com/assu/server/domain/admin/controller/StudentAdminController.java index 6ec802bd..9c64ec8b 100644 --- a/src/main/java/com/assu/server/domain/admin/controller/StudentAdminController.java +++ b/src/main/java/com/assu/server/domain/admin/controller/StudentAdminController.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -17,6 +18,7 @@ @RequiredArgsConstructor @RequestMapping("/admin/dashBoard") @Tag(name = "Admin Dashboard", description = "관리자 대시보드 및 통계 API") +@PreAuthorize("hasRole('ADMIN')") public class StudentAdminController { private final StudentAdminService studentAdminService; diff --git a/src/main/java/com/assu/server/domain/appreview/controller/AppReviewController.java b/src/main/java/com/assu/server/domain/appreview/controller/AppReviewController.java index ae7844d9..6b06b7ca 100644 --- a/src/main/java/com/assu/server/domain/appreview/controller/AppReviewController.java +++ b/src/main/java/com/assu/server/domain/appreview/controller/AppReviewController.java @@ -12,6 +12,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -19,6 +20,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/app-reviews") +@PreAuthorize("hasRole('STUDENT')") public class AppReviewController { private final AppReviewService appReviewService; diff --git a/src/main/java/com/assu/server/domain/certification/controller/CertificationController.java b/src/main/java/com/assu/server/domain/certification/controller/CertificationController.java index 40ce32a8..ad57911b 100644 --- a/src/main/java/com/assu/server/domain/certification/controller/CertificationController.java +++ b/src/main/java/com/assu/server/domain/certification/controller/CertificationController.java @@ -1,6 +1,7 @@ package com.assu.server.domain.certification.controller; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -21,6 +22,7 @@ @RestController @Tag(name = "Certification", description = "QR인증 API") @RequiredArgsConstructor +@PreAuthorize("hasRole('STUDENT')") public class CertificationController { private final CertificationService certificationService; diff --git a/src/main/java/com/assu/server/domain/certification/controller/GroupCertificationController.java b/src/main/java/com/assu/server/domain/certification/controller/GroupCertificationController.java index e7772f53..8dd3df18 100644 --- a/src/main/java/com/assu/server/domain/certification/controller/GroupCertificationController.java +++ b/src/main/java/com/assu/server/domain/certification/controller/GroupCertificationController.java @@ -4,6 +4,7 @@ import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.stereotype.Component; import org.springframework.stereotype.Controller; @@ -22,6 +23,7 @@ @RequiredArgsConstructor @Component @RequestMapping("/app") +@PreAuthorize("hasRole('STUDENT')") public class GroupCertificationController { private final CertificationService certificationService; diff --git a/src/main/java/com/assu/server/domain/chat/controller/ChatController.java b/src/main/java/com/assu/server/domain/chat/controller/ChatController.java index be772ce9..ebb3129c 100644 --- a/src/main/java/com/assu/server/domain/chat/controller/ChatController.java +++ b/src/main/java/com/assu/server/domain/chat/controller/ChatController.java @@ -13,6 +13,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.messaging.handler.annotation.MessageMapping; import org.springframework.messaging.handler.annotation.Payload; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -23,6 +24,7 @@ @Tag(name = "Chatting", description = "채팅 API") @RequiredArgsConstructor @RequestMapping("/chat") +@PreAuthorize("hasAnyRole('ADMIN', 'PARTNER')") public class ChatController { private final ChatService chatService; private final BlockService blockService; diff --git a/src/main/java/com/assu/server/domain/deviceToken/controller/DeviceTokenController.java b/src/main/java/com/assu/server/domain/deviceToken/controller/DeviceTokenController.java index b0f00c84..ee26554e 100644 --- a/src/main/java/com/assu/server/domain/deviceToken/controller/DeviceTokenController.java +++ b/src/main/java/com/assu/server/domain/deviceToken/controller/DeviceTokenController.java @@ -7,6 +7,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -14,6 +15,7 @@ @RestController @RequestMapping("/device-tokens") @RequiredArgsConstructor +@PreAuthorize("hasAnyRole('STUDENT', 'ADMIN', 'PARTNER')") public class DeviceTokenController { private final DeviceTokenService service; diff --git a/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java b/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java index 9754fc88..f3e9b383 100644 --- a/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java +++ b/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java @@ -1,7 +1,6 @@ package com.assu.server.domain.inquiry.controller; import com.assu.server.domain.common.dto.PageResponseDTO; -import com.assu.server.domain.inquiry.dto.InquiryAnswerRequestDTO; import com.assu.server.domain.inquiry.dto.InquiryCreateRequestDTO; import com.assu.server.domain.inquiry.dto.InquiryResponseDTO; import com.assu.server.domain.inquiry.entity.Inquiry; @@ -15,6 +14,7 @@ import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -25,6 +25,7 @@ @Validated @RequestMapping("/inquiries") @RequiredArgsConstructor +@PreAuthorize("hasRole('STUDENTS')") public class InquiryController { private final InquiryService inquiryService; @@ -75,7 +76,6 @@ public BaseResponse> list( return BaseResponse.onSuccess(SuccessStatus._OK, inquiryResponseDTO); } - /** 단건 상세 조회 */ @Operation( summary = "문의 내역 단건 상세 조회 API", description = "# [v1.0 (2025-09-02)](https://www.notion.so/24e1197c19ed800f8a1fffc5a101f3c0?source=copy_link)\n" + @@ -96,29 +96,4 @@ public BaseResponse get( InquiryResponseDTO inquiryResponseDTO = inquiryService.get(inquiryId, pd.getId()); return BaseResponse.onSuccess(SuccessStatus._OK, inquiryResponseDTO); } - - /** 문의 답변 (운영자) */ - @Operation( - summary = "운영자 답변 API", - description = "# [v1.0 (2025-09-02)](https://www.notion.so/24e1197c19ed8064808fcca568b8912a?source=copy_link)\n" + - "- 문의에 답변을 저장하고 상태를 ANSWERED로 변경합니다.\n\n" + - "**Path Variable:**\n" + - "- `inquiryId` (Long, required): 문의 ID\n\n" + - "**Request Body:**\n" + - "- `answer` (String, required): 답변 내용\n\n" + - "**Response:**\n" + - "- 성공 시 200(OK)과 성공 메시지 반환\n" + - "- 400(BAD_REQUEST): 빈 답변 내용\n" + - "- 403(FORBIDDEN): 운영자 권한 없음\n" + - "- 404(NOT_FOUND): 존재하지 않는 문의 ID\n" + - "- 409(CONFLICT): 이미 답변된 문의" - ) - @PatchMapping("/{inquiryId}/answer") - public BaseResponse answer( - @PathVariable("inquiryId") Long inquiryId, - @RequestBody @Valid InquiryAnswerRequestDTO inquiryAnswerRequestDTO - ) { - inquiryService.answer(inquiryId, inquiryAnswerRequestDTO.answer()); - return BaseResponse.onSuccess(SuccessStatus._OK, "The inquiry answered successfully. id=" + inquiryId); - } } \ No newline at end of file diff --git a/src/main/java/com/assu/server/domain/map/controller/MapController.java b/src/main/java/com/assu/server/domain/map/controller/MapController.java index 50e8c70a..1af9f264 100644 --- a/src/main/java/com/assu/server/domain/map/controller/MapController.java +++ b/src/main/java/com/assu/server/domain/map/controller/MapController.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -21,6 +22,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/map") +@PreAuthorize("hasAnyRole('STUDENT','ADMIN','PARTNER')") public class MapController { private final MapService mapService; diff --git a/src/main/java/com/assu/server/domain/member/controller/MemberController.java b/src/main/java/com/assu/server/domain/member/controller/MemberController.java index e408c30f..d2dc56cd 100644 --- a/src/main/java/com/assu/server/domain/member/controller/MemberController.java +++ b/src/main/java/com/assu/server/domain/member/controller/MemberController.java @@ -12,6 +12,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -21,6 +22,7 @@ @RestController @RequestMapping("/members") @RequiredArgsConstructor +@PreAuthorize("hasAnyRole('STUDENT','ADMIN','PARTNER')") public class MemberController { private final ProfileImageService profileImageService; diff --git a/src/main/java/com/assu/server/domain/notification/controller/NotificationController.java b/src/main/java/com/assu/server/domain/notification/controller/NotificationController.java index 5dee9424..81429900 100644 --- a/src/main/java/com/assu/server/domain/notification/controller/NotificationController.java +++ b/src/main/java/com/assu/server/domain/notification/controller/NotificationController.java @@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -21,6 +22,7 @@ @RestController @RequestMapping("/notifications") @RequiredArgsConstructor +@PreAuthorize("hasAnyRole('STUDENT','ADMIN','PARTNER')") public class NotificationController { private final NotificationQueryService notificationQueryService; private final NotificationCommandService notificationCommandService; diff --git a/src/main/java/com/assu/server/domain/partner/controller/PartnerController.java b/src/main/java/com/assu/server/domain/partner/controller/PartnerController.java index 4f0b4683..589b34a1 100644 --- a/src/main/java/com/assu/server/domain/partner/controller/PartnerController.java +++ b/src/main/java/com/assu/server/domain/partner/controller/PartnerController.java @@ -8,6 +8,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -15,6 +16,7 @@ @RestController @RequestMapping("/partner") @RequiredArgsConstructor +@PreAuthorize("hasRole('PARTNER')") public class PartnerController { private final PartnerService partnerService; diff --git a/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java b/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java index b4cd0c02..203ae355 100644 --- a/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java +++ b/src/main/java/com/assu/server/domain/partnership/controller/PartnershipController.java @@ -17,6 +17,7 @@ import org.springframework.data.web.PageableDefault; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -27,38 +28,39 @@ @Tag(name = "Partnership", description = "제휴 제안 API") @RequiredArgsConstructor @RequestMapping("/partnership") +@PreAuthorize("hasAnyRole('STUDENT','ADMIN','PARTNER')") public class PartnershipController { - private final PartnershipService partnershipService; + private final PartnershipService partnershipService; - @PostMapping("/usage") + @PostMapping("/usage") @Operation( - summary = "제휴 사용내역 기록 API", - description = "# [v1.0 (2025-12-23)](https://clumsy-seeder-416.notion.site/2681197c19ed8052804eddd5a1f3ce96?source=copy_link)\n" + - "- 제휴 제공 화면 전에 호출되어 유저의 제휴 내역에 데이터를 기록합니다.\n" + - "- 인증 이후 제휴를 받았다는 때 서버의 데이터 기록을 요청하는 API \n" + - "- 개인 인증 케이스도 포함됩니다.\n\n" + - "**Request Body:**\n" + - " - `storeId` (Long, required): 제휴 매장 ID\n" + - " - `tableNumber` (String, required): 테이블 번호\n" + - " - `adminName` (String, required): 관리자 이름\n" + - " - `placeName` (String, required): 제휴 장소 이름\n" + - " - `partnershipContent` (String, required): 제휴 내용\n" + - " - `contentId` (Long, required): 제휴 컨텐츠 ID\n" + - " - `discount` (Long, optional): 할인 금액\n" + - " - `userIds` (List, optional): 인증 대상 유저 ID 목록\n\n" + - "**Response:**\n" + - " - 성공: 200 OK, `isSuccess=true`, `result=null`\n" + - " - 실패: 적절한 에러 코드 및 메시지" + summary = "제휴 사용내역 기록 API", + description = "# [v1.0 (2025-12-23)](https://clumsy-seeder-416.notion.site/2681197c19ed8052804eddd5a1f3ce96?source=copy_link)\n" + + "- 제휴 제공 화면 전에 호출되어 유저의 제휴 내역에 데이터를 기록합니다.\n" + + "- 인증 이후 제휴를 받았다는 때 서버의 데이터 기록을 요청하는 API \n" + + "- 개인 인증 케이스도 포함됩니다.\n\n" + + "**Request Body:**\n" + + " - `storeId` (Long, required): 제휴 매장 ID\n" + + " - `tableNumber` (String, required): 테이블 번호\n" + + " - `adminName` (String, required): 관리자 이름\n" + + " - `placeName` (String, required): 제휴 장소 이름\n" + + " - `partnershipContent` (String, required): 제휴 내용\n" + + " - `contentId` (Long, required): 제휴 컨텐츠 ID\n" + + " - `discount` (Long, optional): 할인 금액\n" + + " - `userIds` (List, optional): 인증 대상 유저 ID 목록\n\n" + + "**Response:**\n" + + " - 성공: 200 OK, `isSuccess=true`, `result=null`\n" + + " - 실패: 적절한 에러 코드 및 메시지" ) - public ResponseEntity> finalPartnershipRequest( - @AuthenticationPrincipal PrincipalDetails pd, @RequestBody PartnershipFinalRequestDTO dto - ) { - - partnershipService.recordPartnershipUsage(dto, pd.getMember()); + @PreAuthorize("hasRole('STUDENT')") + public ResponseEntity> finalPartnershipRequest( + @AuthenticationPrincipal PrincipalDetails pd, @RequestBody PartnershipFinalRequestDTO dto + ) { + partnershipService.recordPartnershipUsage(dto, pd.getMember()); - return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus.USER_PAPER_REQUEST_SUCCESS, null)); - } + return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus.USER_PAPER_REQUEST_SUCCESS, null)); + } @Operation( summary = "제휴 제안서 초안 생성 API", @@ -72,6 +74,7 @@ public ResponseEntity> finalPartnershipRequest( " - 성공 시 200(OK)과 `CreateDraftResponse` 객체 반환.\n" + " - `paperId` (Long): 생성된 제안서 ID\n") @PostMapping("/proposal/draft") + @PreAuthorize("hasRole('ADMIN')") public BaseResponse createDraftPartnership( @RequestBody PartnershipDraftRequestDTO request, @AuthenticationPrincipal PrincipalDetails pd @@ -145,6 +148,7 @@ public BaseResponse createDraftPartnership( " - `goodsId` (Long): 서비스 제공 항목 ID\n" + " - `goodsName` (String): 서비스 제공 항목명\n") @PostMapping(value = "/passivity", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasRole('ADMIN')") public BaseResponse createManualPartnership( @RequestPart("request") @Parameter ManualPartnershipRequestDTO request, @RequestPart(value = "contractImage") @@ -206,6 +210,7 @@ public BaseResponse createManualPartnership( " - `goodsId` (Long): 서비스 제공 항목 ID\n" + " - `goodsName` (String): 서비스 제공 항목명\n") @PatchMapping("/proposal") + @PreAuthorize("hasAnyRole('ADMIN','PARTNER')") public BaseResponse updatePartnership( @RequestBody WritePartnershipRequestDTO request, @AuthenticationPrincipal PrincipalDetails pd @@ -230,11 +235,14 @@ public BaseResponse updatePartnership( " - `newStatus` (String): 제안서의 이전 상태\n"+ " - `changedAt` (LocalDateTime): 상태 변경 시간\n") @PatchMapping("/{partnershipId}/status") + @PreAuthorize("hasAnyRole('ADMIN','PARTNER')") public BaseResponse updatePartnershipStatus( @PathVariable("partnershipId") @Parameter(required = true) Long partnershipId, - @RequestBody PartnershipStatusUpdateRequestDTO request + @RequestBody PartnershipStatusUpdateRequestDTO request, + @AuthenticationPrincipal PrincipalDetails pd ) { - return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.updatePartnershipStatus(partnershipId, request)); + return BaseResponse.onSuccess(SuccessStatus._OK, + partnershipService.updatePartnershipStatus(partnershipId, request, pd.getId(), pd.getRole())); } @Operation( @@ -267,9 +275,11 @@ public BaseResponse updatePartnershipStatus( " - `goodsName` (String): 서비스 제공 항목명\n") @GetMapping("/{partnershipId}") public BaseResponse getPartnership( - @PathVariable @Parameter(required = true) Long partnershipId + @PathVariable @Parameter(required = true) Long partnershipId, + @AuthenticationPrincipal PrincipalDetails pd ) { - return BaseResponse.onSuccess(SuccessStatus._OK, partnershipService.getPartnership(partnershipId)); + return BaseResponse.onSuccess(SuccessStatus._OK, + partnershipService.getPartnership(partnershipId, pd.getId(), pd.getRole())); } @Operation( @@ -280,10 +290,12 @@ public BaseResponse getPartnership( "\n**Parameters:**\n" + " - `paperId` (Long, required): 삭제할 제안서 ID\n") @DeleteMapping("/proposal/delete/{paperId}") + @PreAuthorize("hasAnyRole('ADMIN','PARTNER')") public BaseResponse deletePartnership( - @PathVariable @Parameter(required = true) Long paperId + @PathVariable @Parameter(required = true) Long paperId, + @AuthenticationPrincipal PrincipalDetails pd ) { - partnershipService.deletePartnership(paperId); + partnershipService.deletePartnership(paperId, pd.getId(), pd.getRole()); return BaseResponse.onSuccess(SuccessStatus._OK, null); } @@ -318,6 +330,7 @@ public BaseResponse deletePartnership( " - `goodsId` (Long): 서비스 제공 항목 ID\n" + " - `goodsName` (String): 서비스 제공 항목명\n") @GetMapping("/admin") + @PreAuthorize("hasRole('ADMIN')") public BaseResponse> listForAdmin( @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, @AuthenticationPrincipal PrincipalDetails pd @@ -356,6 +369,7 @@ public BaseResponse> listForAdmin( " - `goodsId` (Long): 서비스 제공 항목 ID\n" + " - `goodsName` (String): 서비스 제공 항목명\n") @GetMapping("/partner") + @PreAuthorize("hasRole('PARTNER')") public BaseResponse> listForPartner( @PageableDefault(size = 10, sort = "createdAt", direction = Sort.Direction.DESC) Pageable pageable, @AuthenticationPrincipal PrincipalDetails pd @@ -373,6 +387,7 @@ public BaseResponse> listForPartner( " - `partnerName` (String): 제휴업체 이름\n"+ " - `createdAt` (LocalDateTime): 제휴 생성 일자\n") @GetMapping("/suspended") + @PreAuthorize("hasRole('ADMIN')") public BaseResponse> suspendPartnership( @AuthenticationPrincipal PrincipalDetails pd ) { @@ -395,6 +410,7 @@ public BaseResponse> suspendPartnership( " - `partnerName` (String): 제휴업체 이름\n"+ " - `partnerAddress` (String): 제휴업체 주소\n") @GetMapping("/check/admin/{partnerId}") + @PreAuthorize("hasRole('ADMIN')") public BaseResponse checkAdminPartnership( @PathVariable @Parameter(required = true) Long partnerId, @AuthenticationPrincipal PrincipalDetails pd @@ -418,6 +434,7 @@ public BaseResponse checkAdminPartnership( " - `adminName` (String): 관리자 이름\n"+ " - `adminAddress` (String): 관리자 주소\n") @GetMapping("/check/partner/{adminId}") + @PreAuthorize("hasRole('PARTNER')") public BaseResponse checkPartnerPartnership( @PathVariable @Parameter(required = true) Long adminId, @AuthenticationPrincipal PrincipalDetails pd diff --git a/src/main/java/com/assu/server/domain/qr/controller/RedirectController.java b/src/main/java/com/assu/server/domain/qr/controller/RedirectController.java index f6e0710b..15c4052a 100644 --- a/src/main/java/com/assu/server/domain/qr/controller/RedirectController.java +++ b/src/main/java/com/assu/server/domain/qr/controller/RedirectController.java @@ -1,11 +1,13 @@ package com.assu.server.domain.qr.controller; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; @Controller +@PreAuthorize("hasRole('STUDENT')") public class RedirectController { @GetMapping("/verify") public String handleQrRedirect( diff --git a/src/main/java/com/assu/server/domain/qr/controller/TemporaryQrController.java b/src/main/java/com/assu/server/domain/qr/controller/TemporaryQrController.java index f425adf3..bf3de71b 100644 --- a/src/main/java/com/assu/server/domain/qr/controller/TemporaryQrController.java +++ b/src/main/java/com/assu/server/domain/qr/controller/TemporaryQrController.java @@ -3,6 +3,7 @@ import java.util.List; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -27,6 +28,7 @@ @RequiredArgsConstructor @Tag(name="Temp QR", description="임시 QR 인증 API") @RequestMapping("/temporary-qr") +@PreAuthorize("hasRole('STUDENT')") public class TemporaryQrController { private final TemporaryQrService temporaryQrService; diff --git a/src/main/java/com/assu/server/domain/report/controller/ReportController.java b/src/main/java/com/assu/server/domain/report/controller/ReportController.java index 4230d8da..ea167c29 100644 --- a/src/main/java/com/assu/server/domain/report/controller/ReportController.java +++ b/src/main/java/com/assu/server/domain/report/controller/ReportController.java @@ -10,6 +10,7 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -17,6 +18,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/reports") +@PreAuthorize("hasAnyRole('STUDENT', 'ADMIN', 'PARTNER')") public class ReportController { private final ReportService reportService; diff --git a/src/main/java/com/assu/server/domain/review/controller/ReviewController.java b/src/main/java/com/assu/server/domain/review/controller/ReviewController.java index ab8f392d..944e235b 100644 --- a/src/main/java/com/assu/server/domain/review/controller/ReviewController.java +++ b/src/main/java/com/assu/server/domain/review/controller/ReviewController.java @@ -14,6 +14,7 @@ import org.springframework.data.domain.Pageable; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -24,6 +25,7 @@ @RequiredArgsConstructor @RequestMapping("/reviews") @Tag(name = "Review", description = "리뷰 API") +@PreAuthorize("hasAnyRole('STUDENT', 'ADMIN', 'PARTNER')") public class ReviewController { private final ReviewService reviewService; @@ -34,6 +36,7 @@ public class ReviewController { "- Authentication: JWT 토큰 필요 (Student 권한)" ) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasRole('STUDENT')") public BaseResponse writeReview( @AuthenticationPrincipal PrincipalDetails pd, @RequestPart("request") ReviewRequestDTO.WriteReviewRequestDTO request, @@ -48,6 +51,7 @@ public BaseResponse writeReview( "- 로그인한 학생 사용자가 작성한 리뷰 목록을 페이징하여 조회합니다." ) @GetMapping("/student") + @PreAuthorize("hasRole('STUDENT')") public BaseResponse> checkStudent( @AuthenticationPrincipal PrincipalDetails pd, Pageable pageable ) { @@ -60,6 +64,7 @@ public BaseResponse> checkStudent "- 로그인한 파트너 계정의 가게에 달린 리뷰 목록을 페이징하여 조회합니다." ) @GetMapping("/partner") + @PreAuthorize("hasRole('PARTNER')") public BaseResponse> checkPartnerReview( @AuthenticationPrincipal PrincipalDetails pd, Pageable pageable ){ @@ -84,6 +89,7 @@ public BaseResponse> checkStoreRe "- 본인이 작성한 리뷰를 ID를 기반으로 삭제합니다." ) @DeleteMapping("/{reviewId}") + @PreAuthorize("hasRole('STUDENT')") public ResponseEntity> deleteReview( @PathVariable Long reviewId) { return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus._OK, reviewService.deleteReview(reviewId))); @@ -107,6 +113,7 @@ public ResponseEntity> "- 파트너 로그인 시 본인 가게의 평균 평점을 조회합니다." ) @GetMapping("/average") + @PreAuthorize("hasRole('PARTNER')") public ResponseEntity> getMyStoreAverage( @AuthenticationPrincipal PrincipalDetails pd ){ diff --git a/src/main/java/com/assu/server/domain/store/controller/StoreController.java b/src/main/java/com/assu/server/domain/store/controller/StoreController.java index 3d9dad2c..f6ed6bef 100644 --- a/src/main/java/com/assu/server/domain/store/controller/StoreController.java +++ b/src/main/java/com/assu/server/domain/store/controller/StoreController.java @@ -18,9 +18,9 @@ import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.tags.Tag; -import lombok.Getter; import lombok.RequiredArgsConstructor; import com.assu.server.global.util.PrincipalDetails; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import java.util.List; @@ -28,6 +28,7 @@ @RequiredArgsConstructor @Tag(name = "Store", description = "가게 API") @RequestMapping("/store") +@PreAuthorize("hasAnyRole('STUDENT', 'ADMIN', 'BACKOFFICE')") public class StoreController { private final StoreService storeService; @@ -78,6 +79,7 @@ public ResponseEntity> getTodayBestStore() { @Parameters({ @Parameter(name = "storeId", description = "QR에서 추출한 storeId를 입력해주세요") }) + @PreAuthorize("hasRole('STUDENT')") public ResponseEntity> getStorePaperContent(@PathVariable Long storeId, @AuthenticationPrincipal PrincipalDetails pd ) { @@ -115,22 +117,22 @@ public ResponseEntity> getSta " - `rank` (Long): 그 주 순위 (1부터)\n" + " - `usageCount` (Long): 그 주 사용 건수" ) - @GetMapping("/ranking") - public ResponseEntity> getWeeklyRank( - @AuthenticationPrincipal PrincipalDetails pd) { - return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus._OK, storeService.getWeeklyRank(pd.getId()))); - } - - @Operation( - summary = "내 가게 순위 6주치 조회 API", - description = "partnerId로 접근해주세요" - ) - @GetMapping("/ranking/weekly") - public BaseResponse> getWeeklyRankByPartnerId( - @AuthenticationPrincipal PrincipalDetails pd - ){ - return BaseResponse.onSuccess(SuccessStatus._OK, storeService.getListWeeklyRank(pd.getId()).items()); - } - + @GetMapping("/ranking") + @PreAuthorize("hasRole('PARTNER')") + public ResponseEntity> getWeeklyRank( + @AuthenticationPrincipal PrincipalDetails pd) { + return ResponseEntity.ok(BaseResponse.onSuccess(SuccessStatus._OK, storeService.getWeeklyRank(pd.getId()))); + } + @Operation( + summary = "내 가게 순위 6주치 조회 API", + description = "partnerId로 접근해주세요" + ) + @GetMapping("/ranking/weekly") + @PreAuthorize("hasRole('PARTNER')") + public BaseResponse> getWeeklyRankByPartnerId( + @AuthenticationPrincipal PrincipalDetails pd + ){ + return BaseResponse.onSuccess(SuccessStatus._OK, storeService.getListWeeklyRank(pd.getId()).items()); + } } diff --git a/src/main/java/com/assu/server/domain/student/controller/StudentController.java b/src/main/java/com/assu/server/domain/student/controller/StudentController.java index d97c92ae..d7d7579a 100644 --- a/src/main/java/com/assu/server/domain/student/controller/StudentController.java +++ b/src/main/java/com/assu/server/domain/student/controller/StudentController.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -27,6 +28,7 @@ @Tag(name = "Student", description = "학생 API") @RequiredArgsConstructor @RequestMapping("/students") +@PreAuthorize("hasRole('STUDENT')") public class StudentController { private final StudentService studentService; @@ -83,12 +85,13 @@ public ResponseEntity>> getUnr "\n**Response:**\n" + " - stamp 개수 반환 \n" ) - @GetMapping("/stamp") - public BaseResponse getStamp( - @AuthenticationPrincipal PrincipalDetails pd - ) { - return BaseResponse.onSuccess(SuccessStatus._OK, studentService.getStamp(pd.getId())); - } + @GetMapping("/stamp") + public BaseResponse getStamp( + @AuthenticationPrincipal PrincipalDetails pd + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, studentService.getStamp(pd.getId())); + } + @Operation( summary = "스탬프 적립 및 이벤트 응모 API", description = "# [v1.0 (2026-02-23)](https://clumsy-seeder-416.notion.site/3101197c19ed80b5b47eceb202535469)\n" + @@ -124,24 +127,4 @@ public BaseResponse> getUsablePart return BaseResponse.onSuccess(SuccessStatus._OK, studentService.getUsablePartnership(pd.getId(), all)); } - @Operation( - summary = "전체 학생의 사용 가능 제휴 동기화 API", - description = "# [v1.0 (2026-03-16)](https://clumsy-seeder-416.notion.site/3251197c19ed8066885cece9ffc455f6?source=copy_link)\n" + - "- 모든 학생의 user_paper 데이터를 동기화합니다.\n" + - "- 관리자 전용 API입니다.\n" + - "- 시스템 전체에 영향을 주는 작업이므로 주의해서 사용해야 합니다.\n\n" + - "**주의사항:**\n" + - "- 대량의 데이터 처리로 인해 시간이 오래 걸릴 수 있음\n" + - "- 실행 중에는 다른 제휴 관련 작업에 영향을 줄 수 있음\n\n" + - "**Response:**\n" + - "- 성공 시 200(OK)와 동기화 완료 메시지 반환\n" + - "- 403(FORBIDDEN): 관리자 권한 없음\n" + - "- 500(INTERNAL_SERVER_ERROR): 동기화 작업 실패" - ) - @PostMapping("/sync/all") - public BaseResponse syncAllStudentsNow() { - studentService.syncUserPapersForAllStudents(); - return BaseResponse.onSuccess(SuccessStatus._OK, "전체 학생 user_paper 동기화 완료"); - } - } diff --git a/src/main/java/com/assu/server/domain/suggestion/controller/SuggestionController.java b/src/main/java/com/assu/server/domain/suggestion/controller/SuggestionController.java index 3d23d7df..d5546f26 100644 --- a/src/main/java/com/assu/server/domain/suggestion/controller/SuggestionController.java +++ b/src/main/java/com/assu/server/domain/suggestion/controller/SuggestionController.java @@ -11,6 +11,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; @@ -20,6 +21,7 @@ @RestController @RequiredArgsConstructor @RequestMapping("/suggestion") +@PreAuthorize("hasAnyRole('STUDENT', 'ADMIN')") public class SuggestionController { private final SuggestionService suggestionService; @@ -41,6 +43,7 @@ public class SuggestionController { " - `storeName` (String): 희망 가게 이름\n" + " - `suggestionBenefit` (String): 희망 혜택\n") @PostMapping + @PreAuthorize("hasRole('STUDENT')") public BaseResponse writeSuggestion( @RequestBody WriteSuggestionRequestDTO suggestionRequestDTO, @AuthenticationPrincipal PrincipalDetails pd @@ -61,6 +64,7 @@ public BaseResponse writeSuggestion( " - `majorId` (Long): 학부/학과 학생회 ID\n" + " - `majorName` (String): 학부/학과 학생회 이름\n") @GetMapping("/admin") + @PreAuthorize("hasRole('STUDENT')") public BaseResponse getSuggestionAdmins( @AuthenticationPrincipal PrincipalDetails pd ) { @@ -82,6 +86,7 @@ public BaseResponse getSuggestionAdmins( " - `studentMajor` (Long): 건의자의 학부/학과\n" + " - `enrollmentStatus` (EnrollmentStatus): 재학 상태\n") @GetMapping("/list") + @PreAuthorize("hasRole('ADMIN')") public BaseResponse> getSuggestions( @AuthenticationPrincipal PrincipalDetails pd ) { From 61a17fa7ae2eb8615229421fcf8282ecc1805133 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 01:54:58 +0900 Subject: [PATCH 03/19] =?UTF-8?q?[FEAT/#359]=20Backoffice=20=EC=97=AD?= =?UTF-8?q?=ED=95=A0=20=EC=B6=94=EA=B0=80=20/=20=EC=97=94=ED=8B=B0?= =?UTF-8?q?=ED=8B=B0=20=EB=B0=8F=20=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/common/UserBasicInfoDTO.java | 6 ++++ .../backoffice/entity/BackofficeUser.java | 29 +++++++++++++++++++ .../repository/BackofficeUserRepository.java | 9 ++++++ .../server/domain/common/enums/UserRole.java | 2 +- .../server/domain/member/entity/Member.java | 8 ++++- 5 files changed, 52 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/assu/server/domain/backoffice/entity/BackofficeUser.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/repository/BackofficeUserRepository.java diff --git a/src/main/java/com/assu/server/domain/auth/dto/common/UserBasicInfoDTO.java b/src/main/java/com/assu/server/domain/auth/dto/common/UserBasicInfoDTO.java index de474992..ddfcdcf8 100644 --- a/src/main/java/com/assu/server/domain/auth/dto/common/UserBasicInfoDTO.java +++ b/src/main/java/com/assu/server/domain/auth/dto/common/UserBasicInfoDTO.java @@ -51,6 +51,12 @@ public static UserBasicInfoDTO from(Member member) { name = partner.getName(); } } + case BACKOFFICE -> { + var backofficeUser = member.getBackofficeProfile(); + if (backofficeUser != null) { + name = backofficeUser.getName(); + } + } } return new UserBasicInfoDTO(name, university, department, major); diff --git a/src/main/java/com/assu/server/domain/backoffice/entity/BackofficeUser.java b/src/main/java/com/assu/server/domain/backoffice/entity/BackofficeUser.java new file mode 100644 index 00000000..0a189f74 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/entity/BackofficeUser.java @@ -0,0 +1,29 @@ +package com.assu.server.domain.backoffice.entity; + +import com.assu.server.domain.member.entity.Member; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "backoffice_user") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class BackofficeUser { + + @Id + private Long id; + + @OneToOne + @MapsId + @JoinColumn(name = "id") + private Member member; + + @Column(nullable = false, length = 255) + private String name; + + public void updateName(String name) { + this.name = name; + } +} diff --git a/src/main/java/com/assu/server/domain/backoffice/repository/BackofficeUserRepository.java b/src/main/java/com/assu/server/domain/backoffice/repository/BackofficeUserRepository.java new file mode 100644 index 00000000..2b865bce --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/repository/BackofficeUserRepository.java @@ -0,0 +1,9 @@ +package com.assu.server.domain.backoffice.repository; + +import com.assu.server.domain.backoffice.entity.BackofficeUser; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BackofficeUserRepository extends JpaRepository { + + long countAllBy(); +} diff --git a/src/main/java/com/assu/server/domain/common/enums/UserRole.java b/src/main/java/com/assu/server/domain/common/enums/UserRole.java index c505d566..9ac6f26a 100644 --- a/src/main/java/com/assu/server/domain/common/enums/UserRole.java +++ b/src/main/java/com/assu/server/domain/common/enums/UserRole.java @@ -1,5 +1,5 @@ package com.assu.server.domain.common.enums; public enum UserRole { - STUDENT, ADMIN, PARTNER + STUDENT, ADMIN, PARTNER, BACKOFFICE } diff --git a/src/main/java/com/assu/server/domain/member/entity/Member.java b/src/main/java/com/assu/server/domain/member/entity/Member.java index e0b5dfa0..5f3ab21c 100644 --- a/src/main/java/com/assu/server/domain/member/entity/Member.java +++ b/src/main/java/com/assu/server/domain/member/entity/Member.java @@ -1,5 +1,6 @@ package com.assu.server.domain.member.entity; +import com.assu.server.domain.backoffice.entity.BackofficeUser; import com.assu.server.domain.admin.entity.Admin; import com.assu.server.domain.auth.entity.CommonAuth; import com.assu.server.domain.auth.entity.SSUAuth; @@ -42,7 +43,7 @@ public class Member extends BaseEntity { @Column(name = "role", nullable = false) @JdbcTypeCode(SqlTypes.VARCHAR) @NotNull - private UserRole role; // STUDENT, ADMIN, PARTNER + private UserRole role; // STUDENT, ADMIN, PARTNER, BACKOFFICE @Enumerated(EnumType.STRING) @Column(nullable = false) @@ -64,6 +65,9 @@ public class Member extends BaseEntity { @OneToOne(mappedBy = "member", cascade = CascadeType.ALL) private Partner partnerProfile; + @OneToOne(mappedBy = "member", cascade = CascadeType.ALL) + private BackofficeUser backofficeProfile; + // 연관관계 (1:1) — 양방향 필요 없으면 아래 필드 제거해도 됨 @OneToOne(mappedBy = "member", fetch = FetchType.LAZY, cascade = CascadeType.ALL, orphanRemoval = true) private SSUAuth ssuAuth; @@ -78,6 +82,8 @@ public void setProfile(Object profile) { this.partnerProfile = p; } else if (profile instanceof Admin a) { this.adminProfile = a; + } else if (profile instanceof BackofficeUser b) { + this.backofficeProfile = b; } } } From bb26ccd2b1dfb22c5065e53dde81e69049a687ae Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 01:59:14 +0900 Subject: [PATCH 04/19] =?UTF-8?q?[FEAT/#359]=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EA=B3=BC=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EC=97=AC=20Backoffice=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackofficeAuthController.java | 39 +++++ .../security/adapter/CommonAuthAdapter.java | 16 +- .../auth/security/jwt/JwtAuthFilter.java | 158 +++++++++--------- .../domain/auth/security/jwt/JwtUtil.java | 47 +++++- .../auth/service/BackofficeAuthService.java | 11 ++ .../service/BackofficeAuthServiceImpl.java | 80 +++++++++ .../domain/auth/service/LoginServiceImpl.java | 12 +- 7 files changed, 270 insertions(+), 93 deletions(-) create mode 100644 src/main/java/com/assu/server/domain/auth/controller/BackofficeAuthController.java create mode 100644 src/main/java/com/assu/server/domain/auth/service/BackofficeAuthService.java create mode 100644 src/main/java/com/assu/server/domain/auth/service/BackofficeAuthServiceImpl.java diff --git a/src/main/java/com/assu/server/domain/auth/controller/BackofficeAuthController.java b/src/main/java/com/assu/server/domain/auth/controller/BackofficeAuthController.java new file mode 100644 index 00000000..4a62bb6c --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/controller/BackofficeAuthController.java @@ -0,0 +1,39 @@ +package com.assu.server.domain.auth.controller; + +import com.assu.server.domain.auth.dto.login.CommonLoginRequestDTO; +import com.assu.server.domain.auth.dto.login.RefreshResponseDTO; +import com.assu.server.domain.auth.dto.backoffice.BackofficeLoginResponseDTO; +import com.assu.server.domain.auth.service.BackofficeAuthService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Backoffice Auth", description = "백오피스 전용 인증 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/auth/backoffice") +public class BackofficeAuthController { + + private final BackofficeAuthService backofficeAuthService; + + @Operation(summary = "백오피스 로그인 API", description = "BACKOFFICE 역할 계정 전용 로그인. aud=backoffice JWT 발급.") + @PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE) + public BaseResponse login(@RequestBody @Valid CommonLoginRequestDTO request) { + return BaseResponse.onSuccess(SuccessStatus._OK, backofficeAuthService.login(request)); + } + + @Operation(summary = "백오피스 Access Token 갱신 API", description = "aud=backoffice refresh token 전용.") + @PostMapping("/tokens/refresh") + public BaseResponse refresh(@RequestHeader("RefreshToken") String refreshToken) { + return BaseResponse.onSuccess(SuccessStatus._OK, backofficeAuthService.refresh(refreshToken)); + } +} diff --git a/src/main/java/com/assu/server/domain/auth/security/adapter/CommonAuthAdapter.java b/src/main/java/com/assu/server/domain/auth/security/adapter/CommonAuthAdapter.java index 9734a557..4a6c12ca 100644 --- a/src/main/java/com/assu/server/domain/auth/security/adapter/CommonAuthAdapter.java +++ b/src/main/java/com/assu/server/domain/auth/security/adapter/CommonAuthAdapter.java @@ -69,14 +69,14 @@ public void registerCredentials(Member member, String email, String rawPassword) throw new CustomAuthException(ErrorStatus.EXISTED_EMAIL); } String hash = passwordEncoder.encode(rawPassword); - commonAuthRepository.save( - CommonAuth.builder() - .member(member) - .email(email) - .hashedPassword(hash) - .lastLoginAt(LocalDateTime.now()) - .build() - ); + CommonAuth commonAuth = CommonAuth.builder() + .member(member) + .email(email) + .hashedPassword(hash) + .lastLoginAt(LocalDateTime.now()) + .build(); + commonAuthRepository.save(commonAuth); + member.setCommonAuth(commonAuth); } @Override diff --git a/src/main/java/com/assu/server/domain/auth/security/jwt/JwtAuthFilter.java b/src/main/java/com/assu/server/domain/auth/security/jwt/JwtAuthFilter.java index f479848e..683e0a89 100644 --- a/src/main/java/com/assu/server/domain/auth/security/jwt/JwtAuthFilter.java +++ b/src/main/java/com/assu/server/domain/auth/security/jwt/JwtAuthFilter.java @@ -19,20 +19,6 @@ import java.io.IOException; -/** - * JWT 인증 필터. - * - * 책임: - * - 보호 자원에 대해 Authorization 헤더의 Bearer 토큰을 검증하고 SecurityContext에 - * Authentication을 설정한다. - * - 토큰 재발급 엔드포인트(/auth/token/reissue)에서는 - * 1) Access 토큰(만료 허용)의 서명을 검증하고 블랙리스트 여부를 확인, - * 2) Refresh 토큰의 서명/만료를 검증하고 Redis 저장 여부를 확인한 뒤, - * 3) 만료된 Access 토큰에서 Authentication을 복원해 컨텍스트에 주입한다. - * - * 주의: - * - 화이트리스트는 Swagger 등 공개 리소스에 한정한다. /auth/** 전체를 우회시키지 않는다. - */ @RequiredArgsConstructor @Component @Slf4j @@ -44,11 +30,9 @@ public class JwtAuthFilter extends OncePerRequestFilter { private final RedisTemplate redisTemplate; private static final AntPathMatcher PATH = new AntPathMatcher(); - // 공개 경로(필터 우회) private static final String[] WHITELIST = { "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/swagger-resources/**", "/webjars/**", - // Auth (로그아웃/탈퇴/리프레시 제외) "/auth/phone-verification/check-and-send", "/auth/phone-verification/verify", "/auth/email-verification/check", @@ -56,35 +40,27 @@ public class JwtAuthFilter extends OncePerRequestFilter { "/auth/partners/signup", "/auth/admins/signup", "/auth/commons/login", + "/auth/backoffice/login", "/auth/students/login", "/auth/tokens/refresh", + "/auth/backoffice/tokens/refresh", "/auth/students/ssu-verify", - // Actuator 헬스체크 추가 "/actuator/**", }; - /** - * 이 요청에 대해 필터를 적용하지 않을지 여부를 판단하는 함수 - * 화이트리스트 패턴은 우회 - */ @Override protected boolean shouldNotFilter(HttpServletRequest request) { String uri = request.getRequestURI(); if ("OPTIONS".equalsIgnoreCase(request.getMethod())) - return true; // CORS preflight 우회 - if (PATH.match("/auth/tokens/refresh", uri)) - return false; // 토큰 재발급은 필터 적용 + return true; + if (PATH.match("/auth/tokens/refresh", uri) || PATH.match("/auth/backoffice/tokens/refresh", uri)) + return false; for (String p : WHITELIST) if (PATH.match(p, uri)) - return true; // 나머지 공개 경로 우회 - return false; // 보호 자원은 필터 적용 + return true; + return false; } - /** - * Authorization 헤더가 존재하고 Bearer 포맷인지 확인한다. - * - * @throws CustomAuthException 헤더 누락/형식 오류 - */ private static void requireBearerAuthorizationHeader(String authorizationHeader) { if (authorizationHeader == null) { throw new CustomAuthException(ErrorStatus.JWT_TOKEN_NOT_RECEIVED); @@ -94,11 +70,6 @@ private static void requireBearerAuthorizationHeader(String authorizationHeader) } } - /** - * 실제 필터링 로직. - * - 재발급 경로: Access(서명만), Refresh 검증 + 블랙리스트/Redis 확인 후 Authentication 세팅 - * - 일반 보호 경로: Access 검증 + 블랙리스트 확인 후 Authentication 세팅 - */ @Override protected void doFilterInternal( HttpServletRequest request, @@ -109,51 +80,12 @@ protected void doFilterInternal( String authorizationHeader = request.getHeader(jwtHeader); String requestUri = request.getRequestURI(); - // ───────── 재발급 경로 처리 (/auth/token/reissue) ───────── - if (PATH.match("/auth/tokens/refresh", requestUri)) { - String refreshToken = request.getHeader("refreshToken"); - try { - // Bearer 헤더 검증 - requireBearerAuthorizationHeader(authorizationHeader); - if (refreshToken == null) { - throw new CustomAuthException(ErrorStatus.JWT_TOKEN_NOT_RECEIVED); - } - - // Access 토큰: 서명만 검증(만료 허용) 및 블랙리스트 확인(JTI) - String accessToken = jwtUtil.getTokenFromHeader(authorizationHeader); - Claims accessClaims = jwtUtil.validateTokenOnlySignature(accessToken); - String accessJti = accessClaims.getId(); - Boolean accessBlacklisted = redisTemplate.hasKey("blacklist:" + accessJti); - if (Boolean.TRUE.equals(accessBlacklisted)) { - throw new CustomAuthException(ErrorStatus.LOGOUT_USER); - } - - // Refresh 토큰: 서명/만료 검증 + Redis 저장 여부 확인 - jwtUtil.validateRefreshToken(refreshToken); - Claims refreshClaims = jwtUtil.validateTokenOnlySignature(refreshToken); // 만료 전이어야 함 - Long memberIdFromRefresh = ((Number) refreshClaims.get("userId")).longValue(); - String refreshJti = refreshClaims.getId(); - String refreshKey = String.format("refresh:%d:%s", memberIdFromRefresh, refreshJti); - Boolean refreshExists = redisTemplate.hasKey(refreshKey); - if (Boolean.FALSE.equals(refreshExists)) { - // 저장된 RT가 없으면 유효하지 않은 재발급 시도 - throw new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION); - } - - // 컨텍스트에 만료된 Access 토큰으로부터 Authentication 복원 - Authentication authentication = jwtUtil.getAuthenticationFromExpiredAccessToken(accessToken); - SecurityContextHolder.getContext().setAuthentication(authentication); - - chain.doFilter(request, response); - return; - } catch (Exception exception) { - log.error("인증 과정 중, 예상치 못한 예외 발생: {}", exception.getMessage(), exception); - throw new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION); - } + if (PATH.match("/auth/tokens/refresh", requestUri) + || PATH.match("/auth/backoffice/tokens/refresh", requestUri)) { + handleRefresh(request, response, chain, authorizationHeader, requestUri); + return; } - // ───────── 일반 보호 자원 처리 ───────── - // Authorization 헤더가 없거나 Bearer 형식이 아니면 그대로 통과(익명으로 처리됨) if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { chain.doFilter(request, response); return; @@ -161,16 +93,78 @@ protected void doFilterInternal( try { String accessToken = jwtUtil.getTokenFromHeader(authorizationHeader); - - // 블랙리스트 확인(만료 허용 X) + Authentication 복원 jwtUtil.assertNotBlacklisted(accessToken); + Claims claims = jwtUtil.validateToken(accessToken); + assertAudienceForRequest(requestUri, claims); + Authentication authentication = jwtUtil.getAuthentication(accessToken); + SecurityContextHolder.getContext().setAuthentication(authentication); + chain.doFilter(request, response); + } catch (CustomAuthException exception) { + throw exception; + } catch (Exception exception) { + log.error("인증 과정 중, 예상치 못한 예외 발생: {}", exception.getMessage(), exception); + throw new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION); + } + } + private void handleRefresh( + HttpServletRequest request, + HttpServletResponse response, + FilterChain chain, + String authorizationHeader, + String requestUri + ) throws ServletException, IOException { + String refreshToken = request.getHeader("RefreshToken"); + try { + requireBearerAuthorizationHeader(authorizationHeader); + if (refreshToken == null) { + throw new CustomAuthException(ErrorStatus.JWT_TOKEN_NOT_RECEIVED); + } + + String accessToken = jwtUtil.getTokenFromHeader(authorizationHeader); + Claims accessClaims = jwtUtil.validateTokenOnlySignature(accessToken); + String accessJti = accessClaims.getId(); + Boolean accessBlacklisted = redisTemplate.hasKey("blacklist:" + accessJti); + if (Boolean.TRUE.equals(accessBlacklisted)) { + throw new CustomAuthException(ErrorStatus.LOGOUT_USER); + } + + jwtUtil.validateRefreshToken(refreshToken); + Claims refreshClaims = jwtUtil.validateTokenOnlySignature(refreshToken); + Long memberIdFromRefresh = ((Number) refreshClaims.get("userId")).longValue(); + String refreshJti = refreshClaims.getId(); + String refreshKey = String.format("refresh:%d:%s", memberIdFromRefresh, refreshJti); + Boolean refreshExists = redisTemplate.hasKey(refreshKey); + if (Boolean.FALSE.equals(refreshExists)) { + throw new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION); + } + + boolean backofficeRefresh = PATH.match("/auth/backoffice/tokens/refresh", requestUri); + if (backofficeRefresh) { + jwtUtil.assertAudience(refreshClaims, JwtUtil.AUD_BACKOFFICE); + jwtUtil.assertAudience(accessClaims, JwtUtil.AUD_BACKOFFICE); + } else { + jwtUtil.assertAudience(refreshClaims, JwtUtil.AUD_APP); + jwtUtil.assertAudience(accessClaims, JwtUtil.AUD_APP); + } + + Authentication authentication = jwtUtil.getAuthenticationFromExpiredAccessToken(accessToken); SecurityContextHolder.getContext().setAuthentication(authentication); chain.doFilter(request, response); + } catch (CustomAuthException exception) { + throw exception; } catch (Exception exception) { log.error("인증 과정 중, 예상치 못한 예외 발생: {}", exception.getMessage(), exception); throw new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION); } } -} \ No newline at end of file + + private void assertAudienceForRequest(String requestUri, Claims claims) { + if (PATH.match("/backoffice/**", requestUri) || PATH.match("/auth/backoffice/**", requestUri)) { + jwtUtil.assertAudience(claims, JwtUtil.AUD_BACKOFFICE); + return; + } + jwtUtil.assertAudience(claims, JwtUtil.AUD_APP); + } +} diff --git a/src/main/java/com/assu/server/domain/auth/security/jwt/JwtUtil.java b/src/main/java/com/assu/server/domain/auth/security/jwt/JwtUtil.java index 51ea8fe7..75d0631c 100644 --- a/src/main/java/com/assu/server/domain/auth/security/jwt/JwtUtil.java +++ b/src/main/java/com/assu/server/domain/auth/security/jwt/JwtUtil.java @@ -37,12 +37,18 @@ @Profile("!test") public class JwtUtil { + public static final String AUD_APP = "app"; + public static final String AUD_BACKOFFICE = "backoffice"; + @Value("${jwt.secret}") public String secretKey; @Value("${jwt.access-valid-seconds:3600}") // 1시간 기본 private int accessValidSeconds; + @Value("${jwt.backoffice-access-valid-seconds:1800}") + private int backofficeAccessValidSeconds; + @Value("${jwt.refresh-valid-seconds:1209600}") // 14일 기본 private int refreshValidSeconds; @@ -103,16 +109,26 @@ private String generateToken(Map claims, int validSeconds, Strin * @return 발급된 토큰 세트 */ public TokensDTO issueTokens(Long memberId, String username, UserRole role, String authRealm) { + return issueTokens(memberId, username, role, authRealm, AUD_APP); + } + + public TokensDTO issueBackofficeTokens(Long memberId, String username, UserRole role, String authRealm) { + return issueTokens(memberId, username, role, authRealm, AUD_BACKOFFICE); + } + + public TokensDTO issueTokens(Long memberId, String username, UserRole role, String authRealm, String audience) { Map baseClaims = new HashMap<>(); baseClaims.put("userId", memberId); baseClaims.put("username", username); baseClaims.put("role", role.name()); baseClaims.put("authRealm", authRealm); + baseClaims.put("aud", audience); String accessJti = UUID.randomUUID().toString(); String refreshJti = UUID.randomUUID().toString(); - String accessToken = generateToken(baseClaims, accessValidSeconds, accessJti); + int accessSeconds = AUD_BACKOFFICE.equals(audience) ? backofficeAccessValidSeconds : accessValidSeconds; + String accessToken = generateToken(baseClaims, accessSeconds, accessJti); String refreshToken = generateToken(baseClaims, refreshValidSeconds, refreshJti); String refreshKey = String.format("refresh:%d:%s", memberId, refreshJti); @@ -178,7 +194,30 @@ public void validateRefreshToken(String refreshToken) { * @return 인증 객체 */ public Authentication getAuthentication(String accessToken) { - Claims claims = validateToken(accessToken); // 만료/서명 체크 + Claims claims = validateToken(accessToken); + return buildAuthentication(claims); + } + + public void assertAudience(Claims claims, String requiredAudience) { + String audience = resolveAudience(claims); + if (!requiredAudience.equals(audience)) { + throw new CustomAuthException(ErrorStatus.JWT_AUDIENCE_MISMATCH); + } + } + + public String resolveAudience(Claims claims) { + Object aud = claims.get("aud"); + if (aud == null || aud.toString().isBlank()) { + return AUD_APP; + } + return aud.toString(); + } + + public boolean isBackofficeAudience(Claims claims) { + return AUD_BACKOFFICE.equals(resolveAudience(claims)); + } + + private Authentication buildAuthentication(Claims claims) { Long memberId = ((Number) claims.get("userId")).longValue(); String roleName = (String) claims.get("role"); String authRealmName = (String) claims.get("authRealm"); @@ -307,6 +346,7 @@ public TokensDTO rotateRefreshToken(String refreshToken) { String roleString = (String) refreshClaims.get("role"); String authRealm = (String) refreshClaims.get("authRealm"); String refreshJti = refreshClaims.getId(); + String audience = resolveAudience(refreshClaims); UserRole role = UserRole.valueOf(roleString); @@ -319,6 +359,9 @@ public TokensDTO rotateRefreshToken(String refreshToken) { // 4) 기존 RT 삭제 후 새 토큰 발급 redisTemplate.delete(refreshKey); + if (AUD_BACKOFFICE.equals(audience)) { + return issueBackofficeTokens(memberId, username, role, authRealm); + } return issueTokens(memberId, username, role, authRealm); } diff --git a/src/main/java/com/assu/server/domain/auth/service/BackofficeAuthService.java b/src/main/java/com/assu/server/domain/auth/service/BackofficeAuthService.java new file mode 100644 index 00000000..5b014b07 --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/BackofficeAuthService.java @@ -0,0 +1,11 @@ +package com.assu.server.domain.auth.service; + +import com.assu.server.domain.auth.dto.login.CommonLoginRequestDTO; +import com.assu.server.domain.auth.dto.login.RefreshResponseDTO; +import com.assu.server.domain.auth.dto.backoffice.BackofficeLoginResponseDTO; + +public interface BackofficeAuthService { + BackofficeLoginResponseDTO login(CommonLoginRequestDTO request); + + RefreshResponseDTO refresh(String refreshToken); +} diff --git a/src/main/java/com/assu/server/domain/auth/service/BackofficeAuthServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/BackofficeAuthServiceImpl.java new file mode 100644 index 00000000..68a3e74c --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/service/BackofficeAuthServiceImpl.java @@ -0,0 +1,80 @@ +package com.assu.server.domain.auth.service; + +import com.assu.server.domain.auth.dto.login.CommonLoginRequestDTO; +import com.assu.server.domain.auth.dto.login.RefreshResponseDTO; +import com.assu.server.domain.auth.dto.common.TokensDTO; +import com.assu.server.domain.auth.entity.enums.AuthRealm; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.auth.security.adapter.RealmAuthAdapter; +import com.assu.server.domain.auth.security.jwt.JwtUtil; +import com.assu.server.domain.auth.security.token.LoginUsernamePasswordAuthenticationToken; +import com.assu.server.domain.auth.dto.backoffice.BackofficeLoginResponseDTO; +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.GeneralException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class BackofficeAuthServiceImpl implements BackofficeAuthService { + + private final AuthenticationManager authenticationManager; + private final JwtUtil jwtUtil; + private final List realmAuthAdapters; + + @Override + public BackofficeLoginResponseDTO login(CommonLoginRequestDTO request) { + Authentication authentication = authenticationManager.authenticate( + new LoginUsernamePasswordAuthenticationToken( + AuthRealm.COMMON, + request.email(), + request.password() + ) + ); + + RealmAuthAdapter adapter = pickAdapter(AuthRealm.COMMON); + Member member = adapter.loadMember(authentication.getName()); + + if (member.getRole() != UserRole.BACKOFFICE) { + throw new GeneralException(ErrorStatus.NO_BACKOFFICE_TYPE); + } + + TokensDTO tokens = jwtUtil.issueBackofficeTokens( + member.getId(), + authentication.getName(), + member.getRole(), + adapter.authRealmValue() + ); + + return BackofficeLoginResponseDTO.from(member, tokens); + } + + @Override + @Transactional(propagation = Propagation.NOT_SUPPORTED) + public RefreshResponseDTO refresh(String refreshToken) { + io.jsonwebtoken.Claims refreshClaims = jwtUtil.validateTokenOnlySignature(refreshToken); + if (!jwtUtil.isBackofficeAudience(refreshClaims)) { + throw new GeneralException(ErrorStatus.JWT_AUDIENCE_MISMATCH); + } + + TokensDTO rotated = jwtUtil.rotateRefreshToken(refreshToken); + Long memberId = ((Number) jwtUtil.validateTokenOnlySignature(rotated.accessToken()).get("userId")).longValue(); + return RefreshResponseDTO.from(memberId, rotated); + } + + private RealmAuthAdapter pickAdapter(AuthRealm realm) { + return realmAuthAdapters.stream() + .filter(a -> a.supports(realm)) + .findFirst() + .orElseThrow(() -> new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION)); + } +} diff --git a/src/main/java/com/assu/server/domain/auth/service/LoginServiceImpl.java b/src/main/java/com/assu/server/domain/auth/service/LoginServiceImpl.java index 01afad31..63bd4115 100644 --- a/src/main/java/com/assu/server/domain/auth/service/LoginServiceImpl.java +++ b/src/main/java/com/assu/server/domain/auth/service/LoginServiceImpl.java @@ -14,11 +14,13 @@ import com.assu.server.domain.auth.security.jwt.JwtUtil; import com.assu.server.domain.auth.security.token.LoginUsernamePasswordAuthenticationToken; import com.assu.server.domain.common.entity.enums.Major; +import com.assu.server.domain.common.enums.UserRole; import com.assu.server.domain.member.entity.Member; import com.assu.server.domain.student.entity.Student; import com.assu.server.domain.common.entity.enums.EnrollmentStatus; import com.assu.server.domain.student.repository.StudentRepository; import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.global.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.core.Authentication; @@ -36,7 +38,6 @@ public class LoginServiceImpl implements LoginService { private final AuthenticationManager authenticationManager; private final JwtUtil jwtUtil; private final SSUAuthService ssuAuthService; - private final CommonAuthRepository commonAuthRepository; private final StudentRepository studentRepository; private final List realmAuthAdapters; @@ -67,6 +68,10 @@ public LoginResponseDTO loginCommon(CommonLoginRequestDTO request) { Member member = adapter.loadMember(authentication.getName()); + if (member.getRole() == UserRole.BACKOFFICE) { + throw new GeneralException(ErrorStatus.BACKOFFICE_USE_DEDICATED_LOGIN); + } + // 토큰 발급 (Access 미저장, Refresh는 Redis 저장) TokensDTO tokens = jwtUtil.issueTokens( member.getId(), @@ -144,6 +149,11 @@ public LoginResponseDTO loginSsuStudent(StudentTokenAuthPayloadDTO request) { @Override @Transactional(propagation = Propagation.NOT_SUPPORTED) public RefreshResponseDTO refresh(String refreshToken) { + io.jsonwebtoken.Claims refreshClaims = jwtUtil.validateTokenOnlySignature(refreshToken); + if (jwtUtil.isBackofficeAudience(refreshClaims)) { + throw new GeneralException(ErrorStatus.BACKOFFICE_USE_DEDICATED_LOGIN); + } + TokensDTO rotated = jwtUtil.rotateRefreshToken(refreshToken); Long memberId = ((Number) jwtUtil.validateTokenOnlySignature(rotated.accessToken()).get("userId")).longValue(); From bf7105d59ffa1da13a96612f0b630fc7d6870a18 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:00:03 +0900 Subject: [PATCH 05/19] =?UTF-8?q?[FEAT/#359]=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EC=9A=B4=EC=98=81=20API=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BackofficeInquiryController.java | 38 +++++++++++++++++++ .../BackofficeStudentController.java | 31 +++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 src/main/java/com/assu/server/domain/backoffice/controller/BackofficeInquiryController.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/controller/BackofficeStudentController.java diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeInquiryController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeInquiryController.java new file mode 100644 index 00000000..98fd2c62 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeInquiryController.java @@ -0,0 +1,38 @@ +package com.assu.server.domain.backoffice.controller; + +import com.assu.server.domain.backoffice.annotation.BackofficeAudited; +import com.assu.server.domain.inquiry.dto.InquiryAnswerRequestDTO; +import com.assu.server.domain.inquiry.service.InquiryService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +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; + +@Tag(name = "Backoffice", description = "백오피스 운영 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/backoffice/inquiries") +@PreAuthorize("hasRole('BACKOFFICE')") +public class BackofficeInquiryController { + + private final InquiryService inquiryService; + + @BackofficeAudited(action = "INQUIRY_ANSWER", targetId = "#inquiryId") + @Operation(summary = "운영자 문의 답변 API") + @PatchMapping("/{inquiryId}/answer") + public BaseResponse answer( + @PathVariable("inquiryId") Long inquiryId, + @RequestBody @Valid InquiryAnswerRequestDTO inquiryAnswerRequestDTO + ) { + inquiryService.answer(inquiryId, inquiryAnswerRequestDTO.answer()); + return BaseResponse.onSuccess(SuccessStatus._OK, "The inquiry answered successfully. id=" + inquiryId); + } +} diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeStudentController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeStudentController.java new file mode 100644 index 00000000..0bd4bfd1 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeStudentController.java @@ -0,0 +1,31 @@ +package com.assu.server.domain.backoffice.controller; + +import com.assu.server.domain.backoffice.annotation.BackofficeAudited; +import com.assu.server.domain.student.service.StudentService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Backoffice", description = "백오피스 운영 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/backoffice/students") +@PreAuthorize("hasRole('BACKOFFICE')") +public class BackofficeStudentController { + + private final StudentService studentService; + + @BackofficeAudited(action = "STUDENT_SYNC") + @Operation(summary = "전체 학생 user_paper 동기화 API") + @PostMapping("/sync") + public BaseResponse syncAllStudentsNow() { + studentService.syncUserPapersForAllStudents(); + return BaseResponse.onSuccess(SuccessStatus._OK, "전체 학생 user_paper 동기화 완료"); + } +} From 491440cb037b2e230b0cf50fa3480c2580c20ec2 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:02:14 +0900 Subject: [PATCH 06/19] =?UTF-8?q?[FEAT/#359]=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20?= =?UTF-8?q?Role=20=EA=B8=B0=EB=B0=98=20=EA=B6=8C=ED=95=9C=20=EA=B0=95?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../NotificationCommandServiceImpl.java | 1 + .../service/NotificationQueryServiceImpl.java | 9 ++++-- .../service/PartnershipService.java | 13 +++++++-- .../service/PartnershipServiceImpl.java | 28 +++++++++++++++++-- 4 files changed, 42 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/assu/server/domain/notification/service/NotificationCommandServiceImpl.java b/src/main/java/com/assu/server/domain/notification/service/NotificationCommandServiceImpl.java index 08a2e36e..e0693d29 100644 --- a/src/main/java/com/assu/server/domain/notification/service/NotificationCommandServiceImpl.java +++ b/src/main/java/com/assu/server/domain/notification/service/NotificationCommandServiceImpl.java @@ -256,6 +256,7 @@ private Map buildToggleResult(Long memberId, UserRole role) { case ADMIN -> EnumSet.of(NotificationType.CHAT, NotificationType.PARTNER_SUGGESTION, NotificationType.PARTNER_PROPOSAL); case PARTNER -> EnumSet.of(NotificationType.CHAT, NotificationType.ORDER); case STUDENT -> EnumSet.of(NotificationType.STAMP); + case BACKOFFICE -> EnumSet.noneOf(NotificationType.class); }; Map result = new LinkedHashMap<>(); diff --git a/src/main/java/com/assu/server/domain/notification/service/NotificationQueryServiceImpl.java b/src/main/java/com/assu/server/domain/notification/service/NotificationQueryServiceImpl.java index 0fdde819..574bfafe 100644 --- a/src/main/java/com/assu/server/domain/notification/service/NotificationQueryServiceImpl.java +++ b/src/main/java/com/assu/server/domain/notification/service/NotificationQueryServiceImpl.java @@ -79,9 +79,12 @@ private void validateParams(int page, int size, Long memberId) { } private Set getVisibleTypes(UserRole role) { - return role == UserRole.ADMIN - ? EnumSet.of(NotificationType.CHAT, NotificationType.PARTNER_SUGGESTION, NotificationType.PARTNER_PROPOSAL) - : EnumSet.of(NotificationType.CHAT, NotificationType.ORDER); + return switch (role) { + case ADMIN -> EnumSet.of(NotificationType.CHAT, NotificationType.PARTNER_SUGGESTION, NotificationType.PARTNER_PROPOSAL); + case PARTNER -> EnumSet.of(NotificationType.CHAT, NotificationType.ORDER); + case STUDENT -> EnumSet.of(NotificationType.STAMP); + case BACKOFFICE -> EnumSet.noneOf(NotificationType.class); + }; } private Map buildSettings(Long memberId, Set visible) { diff --git a/src/main/java/com/assu/server/domain/partnership/service/PartnershipService.java b/src/main/java/com/assu/server/domain/partnership/service/PartnershipService.java index b1a1dcdf..c82e7c3e 100644 --- a/src/main/java/com/assu/server/domain/partnership/service/PartnershipService.java +++ b/src/main/java/com/assu/server/domain/partnership/service/PartnershipService.java @@ -1,6 +1,7 @@ package com.assu.server.domain.partnership.service; import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.common.enums.UserRole; import com.assu.server.domain.partnership.dto.*; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -17,16 +18,22 @@ public interface PartnershipService { Page listPartnershipsForAdmin(Pageable pageable, Long adminId); Page listPartnershipsForPartner(Pageable pageable, Long partnerId); - PartnershipDetailResponseDTO getPartnership(Long partnershipId); + PartnershipDetailResponseDTO getPartnership(Long partnershipId, Long memberId, UserRole role); + List getSuspendedPapers(Long adminId); - PartnershipStatusUpdateResponseDTO updatePartnershipStatus(Long partnershipId, PartnershipStatusUpdateRequestDTO request); + PartnershipStatusUpdateResponseDTO updatePartnershipStatus( + Long partnershipId, + PartnershipStatusUpdateRequestDTO request, + Long memberId, + UserRole role + ); ManualPartnershipResponseDTO createManualPartnership(ManualPartnershipRequestDTO request, Long adminId, MultipartFile contractImage); PartnershipDraftResponseDTO createDraftPartnership(PartnershipDraftRequestDTO request, Long adminId); - void deletePartnership(Long paperId); + void deletePartnership(Long paperId, Long memberId, UserRole role); AdminPartnershipCheckResponseDTO checkPartnershipWithPartner(Long adminId, Long partnerId); PartnerPartnershipCheckResponseDTO checkPartnershipWithAdmin(Long partnerId, Long adminId); diff --git a/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java b/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java index 416b13c0..e51569cc 100644 --- a/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java +++ b/src/main/java/com/assu/server/domain/partnership/service/PartnershipServiceImpl.java @@ -7,6 +7,7 @@ import com.assu.server.domain.chat.repository.ChatRepository; import com.assu.server.domain.chat.service.ChatService; import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.common.enums.UserRole; import com.assu.server.domain.member.entity.Member; import com.assu.server.domain.notification.service.NotificationCommandService; import com.assu.server.domain.partner.entity.Partner; @@ -186,9 +187,10 @@ public Page listPartnershipsForPartner(Pageable pag @Override @Transactional(readOnly = true) - public PartnershipDetailResponseDTO getPartnership(Long partnershipId) { + public PartnershipDetailResponseDTO getPartnership(Long partnershipId, Long memberId, UserRole role) { Paper paper = paperRepository.findById(partnershipId) .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PAPER)); + assertPaperAccess(paper, memberId, role); List contents = paperContentRepository.findAllByOnePaperIdInFetchGoods(partnershipId); @@ -212,9 +214,15 @@ public List getSuspendedPapers(Long adminId) { @Override @Transactional - public PartnershipStatusUpdateResponseDTO updatePartnershipStatus(Long partnershipId, PartnershipStatusUpdateRequestDTO request) { + public PartnershipStatusUpdateResponseDTO updatePartnershipStatus( + Long partnershipId, + PartnershipStatusUpdateRequestDTO request, + Long memberId, + UserRole role + ) { Paper paper = paperRepository.findById(partnershipId) .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PAPER)); + assertPaperAccess(paper, memberId, role); if(request == null || request.status() == null){ throw new DatabaseException(ErrorStatus._BAD_REQUEST); @@ -381,9 +389,10 @@ public PartnershipDraftResponseDTO createDraftPartnership(PartnershipDraftReques @Override @Transactional - public void deletePartnership(Long paperId) { + public void deletePartnership(Long paperId, Long memberId, UserRole role) { Paper paper = paperRepository.findById(paperId) .orElseThrow(() -> new DatabaseException(ErrorStatus.NO_SUCH_PAPER)); + assertPaperAccess(paper, memberId, role); // 0. paperContent + goods 삭제 List contentsToDelete = paperContentRepository.findByPaperId(paperId); @@ -416,6 +425,7 @@ public void deletePartnership(Long paperId) { } } + // Todo: 추후 checkPartnershipWithPartner와 checkPartnershipWithAdmin를 Role 기반 로직으로 변경하여 메소드 합칠 것 @Override @Transactional(readOnly = true) public AdminPartnershipCheckResponseDTO checkPartnershipWithPartner(Long adminId, Long partnerId) { @@ -501,4 +511,16 @@ private ActivationStatus parseStatus(String raw) { private String pickDisplayAddress(String road, String jibun) { return (road != null && !road.isBlank()) ? road : jibun; } + + private void assertPaperAccess(Paper paper, Long memberId, UserRole role) { + if (role == UserRole.ADMIN && paper.getAdmin().getId().equals(memberId)) { + return; + } + if (role == UserRole.PARTNER + && paper.getPartner() != null + && paper.getPartner().getId().equals(memberId)) { + return; + } + throw new GeneralException(ErrorStatus._FORBIDDEN); + } } From c09518d652cf7b0ee7ec066e19faca7843c8c26a Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:04:01 +0900 Subject: [PATCH 07/19] =?UTF-8?q?[FEAT/#359]=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EC=B6=94=EA=B0=80=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/global/apiPayload/code/status/ErrorStatus.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java index e161c2b2..6b3d89e6 100644 --- a/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/assu/server/global/apiPayload/code/status/ErrorStatus.java @@ -44,6 +44,11 @@ public enum ErrorStatus implements BaseErrorCode { // 멤버 에러 NO_SUCH_MEMBER(HttpStatus.NOT_FOUND,"MEMBER_4001","존재하지 않는 멤버 ID입니다."), NO_STUDENT_TYPE(HttpStatus.BAD_REQUEST, "MEMBER4002", "학생 타입이 아닌 멤버입니다."), + NO_BACKOFFICE_TYPE(HttpStatus.FORBIDDEN, "MEMBER4011", "백오피스 운영자 타입이 아닌 멤버입니다."), + JWT_AUDIENCE_MISMATCH(HttpStatus.UNAUTHORIZED, "AUTH4008", "토큰 audience가 요청과 일치하지 않습니다."), + BACKOFFICE_BOOTSTRAP_DISABLED(HttpStatus.BAD_REQUEST, "BACKOFFICE4001", "백오피스 bootstrap이 비활성화되어 있습니다."), + LAST_BACKOFFICE_OPERATOR(HttpStatus.BAD_REQUEST, "BACKOFFICE4002", "마지막 백오피스 운영자는 비활성화할 수 없습니다."), + BACKOFFICE_USE_DEDICATED_LOGIN(HttpStatus.FORBIDDEN, "BACKOFFICE4003", "백오피스 계정은 /auth/backoffice/login을 사용해야 합니다."), NO_SUCH_ADMIN(HttpStatus.NOT_FOUND,"MEMBER_4002","존재하지 않는 admin ID 입니다."), NO_SUCH_PARTNER(HttpStatus.NOT_FOUND,"MEMBER_4003","존재하지 않는 partner ID 입니다."), From 0e1ecc122d8792823ceb7a7eb4d13d4c103a7e86 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:05:44 +0900 Subject: [PATCH 08/19] =?UTF-8?q?[FEAT/#359]=20@PreAuthorize=20=ED=99=9C?= =?UTF-8?q?=EC=84=B1=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../assu/server/global/config/MethodSecurityConfig.java | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 src/main/java/com/assu/server/global/config/MethodSecurityConfig.java diff --git a/src/main/java/com/assu/server/global/config/MethodSecurityConfig.java b/src/main/java/com/assu/server/global/config/MethodSecurityConfig.java new file mode 100644 index 00000000..0507557d --- /dev/null +++ b/src/main/java/com/assu/server/global/config/MethodSecurityConfig.java @@ -0,0 +1,9 @@ +package com.assu.server.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; + +@Configuration +@EnableMethodSecurity +public class MethodSecurityConfig { +} From 5e7b09767efc5fae803003a8a087038c8caf6313 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:14:05 +0900 Subject: [PATCH 09/19] =?UTF-8?q?[FEAT/#359]=20401,=20403=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EA=B4=80=EB=A0=A8=20=EC=97=90=EB=9F=AC=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EA=B7=9C=EA=B2=A9=20=ED=86=B5=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../exception/GlobalExceptionAdvice.java | 8 ++++++ .../security/RestAccessDeniedHandler.java | 25 +++++++++++++++++++ .../RestAuthenticationEntryPoint.java | 25 +++++++++++++++++++ .../security/SecurityErrorResponseWriter.java | 24 ++++++++++++++++++ 4 files changed, 82 insertions(+) create mode 100644 src/main/java/com/assu/server/global/security/RestAccessDeniedHandler.java create mode 100644 src/main/java/com/assu/server/global/security/RestAuthenticationEntryPoint.java create mode 100644 src/main/java/com/assu/server/global/security/SecurityErrorResponseWriter.java diff --git a/src/main/java/com/assu/server/global/exception/GlobalExceptionAdvice.java b/src/main/java/com/assu/server/global/exception/GlobalExceptionAdvice.java index 0794633c..b4710c15 100644 --- a/src/main/java/com/assu/server/global/exception/GlobalExceptionAdvice.java +++ b/src/main/java/com/assu/server/global/exception/GlobalExceptionAdvice.java @@ -14,6 +14,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.MissingServletRequestParameterException; +import org.springframework.security.access.AccessDeniedException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestControllerAdvice; @@ -116,6 +117,13 @@ public ResponseEntity onThrowException( return handleExceptionInternal(generalException, errorReasonHttpStatus, null, request); } + @ExceptionHandler(AccessDeniedException.class) + public ResponseEntity handleAccessDeniedException( + AccessDeniedException exception, HttpServletRequest request) { + ErrorReasonDTO reason = ErrorStatus._FORBIDDEN.getReasonHttpStatus(); + return handleExceptionInternal(exception, reason, null, request); + } + private ResponseEntity handleExceptionInternal( Exception e, ErrorReasonDTO reason, HttpHeaders headers, HttpServletRequest request) { diff --git a/src/main/java/com/assu/server/global/security/RestAccessDeniedHandler.java b/src/main/java/com/assu/server/global/security/RestAccessDeniedHandler.java new file mode 100644 index 00000000..7be898c6 --- /dev/null +++ b/src/main/java/com/assu/server/global/security/RestAccessDeniedHandler.java @@ -0,0 +1,25 @@ +package com.assu.server.global.security; + +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class RestAccessDeniedHandler implements AccessDeniedHandler { + + private final ObjectMapper objectMapper; + + @Override + public void handle(HttpServletRequest request, HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException { + SecurityErrorResponseWriter.write(response, objectMapper, ErrorStatus._FORBIDDEN); + } +} diff --git a/src/main/java/com/assu/server/global/security/RestAuthenticationEntryPoint.java b/src/main/java/com/assu/server/global/security/RestAuthenticationEntryPoint.java new file mode 100644 index 00000000..7f6d90fc --- /dev/null +++ b/src/main/java/com/assu/server/global/security/RestAuthenticationEntryPoint.java @@ -0,0 +1,25 @@ +package com.assu.server.global.security; + +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private final ObjectMapper objectMapper; + + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + SecurityErrorResponseWriter.write(response, objectMapper, ErrorStatus._UNAUTHORIZED); + } +} diff --git a/src/main/java/com/assu/server/global/security/SecurityErrorResponseWriter.java b/src/main/java/com/assu/server/global/security/SecurityErrorResponseWriter.java new file mode 100644 index 00000000..00b3eedd --- /dev/null +++ b/src/main/java/com/assu/server/global/security/SecurityErrorResponseWriter.java @@ -0,0 +1,24 @@ +package com.assu.server.global.security; + +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.http.MediaType; + +import java.io.IOException; + +final class SecurityErrorResponseWriter { + + private SecurityErrorResponseWriter() { + } + + static void write(HttpServletResponse response, ObjectMapper objectMapper, ErrorStatus status) + throws IOException { + response.setStatus(status.getHttpStatus().value()); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setCharacterEncoding("UTF-8"); + BaseResponse body = BaseResponse.onFailure(status.getCode(), status.getMessage(), null); + objectMapper.writeValue(response.getWriter(), body); + } +} From 3c52471fe39050c94421be674341cdf5bd8aaeaf Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:15:16 +0900 Subject: [PATCH 10/19] =?UTF-8?q?[FEAT/#359]=20401,=20403=20=EA=B6=8C?= =?UTF-8?q?=ED=95=9C=20=EA=B4=80=EB=A0=A8=20=EC=97=90=EB=9F=AC=EC=8B=9C=20?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20=EA=B7=9C=EA=B2=A9=20=ED=86=B5=EC=9D=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20=ED=95=B8=EB=93=A4=EB=9F=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20URL=20RBAC=20(/backoffice/**,=20/admin/?= =?UTF-8?q?**,=20/partner/**),=20permitAll=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/global/config/SecurityConfig.java | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/src/main/java/com/assu/server/global/config/SecurityConfig.java b/src/main/java/com/assu/server/global/config/SecurityConfig.java index e7e809ab..0926a3bc 100644 --- a/src/main/java/com/assu/server/global/config/SecurityConfig.java +++ b/src/main/java/com/assu/server/global/config/SecurityConfig.java @@ -1,6 +1,8 @@ package com.assu.server.global.config; import com.assu.server.domain.auth.security.jwt.JwtAuthFilter; +import com.assu.server.global.security.RestAccessDeniedHandler; +import com.assu.server.global.security.RestAuthenticationEntryPoint; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -14,26 +16,29 @@ public class SecurityConfig { @Bean - public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception { + public SecurityFilterChain filterChain( + HttpSecurity http, + JwtAuthFilter jwtAuthFilter, + RestAuthenticationEntryPoint authenticationEntryPoint, + RestAccessDeniedHandler accessDeniedHandler + ) throws Exception { http .csrf(csrf -> csrf.disable()) - .cors(cors -> {}) // 기본 CORS 구성 사용(필요하면 CorsConfigurationSource 빈 추가) + .cors(cors -> {}) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(ex -> ex + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler)) .authorizeHttpRequests(auth -> auth .requestMatchers("/verify").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() - - // ✅ WebSocket 핸드셰이크 허용 (네이티브 + SockJS 모두 포함) .requestMatchers("/ws/**","/ws").permitAll() - - // Swagger 등 공개 리소스 .requestMatchers( "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html", "/swagger-resources/**", "/webjars/**" ).permitAll() - // 헬스 체크 용 .requestMatchers("/actuator/**").permitAll() - .requestMatchers(// Auth (로그아웃 제외) + .requestMatchers( "/auth/phone-verification/check-and-send", "/auth/phone-verification/verify", "/auth/email-verification/check", @@ -41,31 +46,27 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwtAuthF "/auth/partners/signup", "/auth/admins/signup", "/auth/commons/login", + "/auth/backoffice/login", "/auth/students/login", "/auth/tokens/refresh", - "/auth/students/ssu-verify", - "/map/place", - "/member/inquiries/{inquiry-id}/answer" + "/auth/backoffice/tokens/refresh", + "/auth/students/ssu-verify" ).permitAll() - .requestMatchers("/ws/**").permitAll() - // 나머지는 인증 필요 - + .requestMatchers("/backoffice/**").hasRole("BACKOFFICE") + .requestMatchers("/admin/**").hasRole("ADMIN") + .requestMatchers("/partner/**").hasRole("PARTNER") .anyRequest().authenticated() ) .formLogin(form -> form.disable()) .httpBasic(basic -> basic.disable()) - .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); - + .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } - @Bean public WebSecurityCustomizer webSecurityCustomizer() { - // /actuator로 시작하는 모든 요청은 보안 필터 체인 자체를 거치지 않게 합니다. return (web) -> web.ignoring() - .requestMatchers("/actuator/**"); + .requestMatchers("/actuator/**"); } - } From cc595a2f03464a40ca16b3cc8a5d736db44ddff0 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:15:50 +0900 Subject: [PATCH 11/19] =?UTF-8?q?[FEAT/#359]=20Spring=20AOP=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index b513f91d..14ccfd75 100644 --- a/build.gradle +++ b/build.gradle @@ -33,6 +33,7 @@ dependencies { // spring security implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-aop' testImplementation 'org.springframework.security:spring-security-test' // redis From a7bac8003609654f378c590d0d542a5d5fac5104 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:22:54 +0900 Subject: [PATCH 12/19] =?UTF-8?q?[FEAT/#359]=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EA=B3=BC=20=EB=B6=84=EB=A6=AC?= =?UTF-8?q?=ED=95=98=EC=97=AC=20Backoffice=20=EC=A0=84=EC=9A=A9=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BackofficeLoginResponseDTO.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 src/main/java/com/assu/server/domain/auth/dto/backoffice/BackofficeLoginResponseDTO.java diff --git a/src/main/java/com/assu/server/domain/auth/dto/backoffice/BackofficeLoginResponseDTO.java b/src/main/java/com/assu/server/domain/auth/dto/backoffice/BackofficeLoginResponseDTO.java new file mode 100644 index 00000000..b2ff0cca --- /dev/null +++ b/src/main/java/com/assu/server/domain/auth/dto/backoffice/BackofficeLoginResponseDTO.java @@ -0,0 +1,36 @@ +package com.assu.server.domain.auth.dto.backoffice; + +import com.assu.server.domain.auth.dto.common.TokensDTO; +import com.assu.server.domain.auth.dto.common.UserBasicInfoDTO; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.member.entity.Member; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "백오피스 로그인 성공 응답") +public record BackofficeLoginResponseDTO( + @Schema(description = "회원 ID", example = "1") + Long memberId, + + @Schema(description = "회원 역할", example = "BACKOFFICE") + UserRole role, + + @Schema(description = "회원 상태", example = "ACTIVE") + ActivationStatus status, + + @Schema(description = "액세스/리프레시 토큰") + TokensDTO tokens, + + @Schema(description = "운영자 기본 정보") + UserBasicInfoDTO basicInfo +) { + public static BackofficeLoginResponseDTO from(Member member, TokensDTO tokens) { + return new BackofficeLoginResponseDTO( + member.getId(), + member.getRole(), + member.getIsActivated(), + tokens, + UserBasicInfoDTO.from(member) + ); + } +} From c57c1bcdc2c9027a1909fc45e5daba4e4f3ce461 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:28:30 +0900 Subject: [PATCH 13/19] =?UTF-8?q?[FEAT/#359]=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EA=B3=84=EC=A0=95=20=EC=B4=88=EA=B8=B0=EC=84=B8?= =?UTF-8?q?=ED=8C=85=EC=9D=84=20=EB=8F=95=EA=B8=B0=20=EC=9C=84=ED=95=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=8B=88=EC=85=9C=EB=9D=BC=EC=9D=B4=EC=A0=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BackofficeBootstrapInitializer.java | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 src/main/java/com/assu/server/domain/backoffice/bootstrap/BackofficeBootstrapInitializer.java diff --git a/src/main/java/com/assu/server/domain/backoffice/bootstrap/BackofficeBootstrapInitializer.java b/src/main/java/com/assu/server/domain/backoffice/bootstrap/BackofficeBootstrapInitializer.java new file mode 100644 index 00000000..8ca7f393 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/bootstrap/BackofficeBootstrapInitializer.java @@ -0,0 +1,58 @@ +package com.assu.server.domain.backoffice.bootstrap; + +import com.assu.server.domain.backoffice.dto.BackofficeOperatorCreateRequestDTO; +import com.assu.server.domain.backoffice.repository.BackofficeUserRepository; +import com.assu.server.domain.backoffice.service.BackofficeOperatorService; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@ConditionalOnProperty(name = "backoffice.bootstrap.enabled", havingValue = "true") +public class BackofficeBootstrapInitializer { + + private final BackofficeUserRepository backofficeUserRepository; + private final BackofficeOperatorService backofficeOperatorService; + private final String bootstrapEmail; + private final String bootstrapPassword; + private final String bootstrapName; + + public BackofficeBootstrapInitializer( + BackofficeUserRepository backofficeUserRepository, + BackofficeOperatorService backofficeOperatorService, + @Value("${backoffice.bootstrap.email:}") String bootstrapEmail, + @Value("${backoffice.bootstrap.password:}") String bootstrapPassword, + @Value("${backoffice.bootstrap.name:}") String bootstrapName + ) { + this.backofficeUserRepository = backofficeUserRepository; + this.backofficeOperatorService = backofficeOperatorService; + this.bootstrapEmail = bootstrapEmail; + this.bootstrapPassword = bootstrapPassword; + this.bootstrapName = bootstrapName; + } + + @EventListener(ApplicationReadyEvent.class) + public void bootstrapInitialOperator() { + if (backofficeUserRepository.countAllBy() > 0) { + return; + } + + if (bootstrapEmail.isBlank() || bootstrapPassword.isBlank() || bootstrapName.isBlank()) { + log.warn("Backoffice bootstrap enabled but credentials are incomplete. Skipping initial operator creation."); + return; + } + + backofficeOperatorService.createOperator( + new BackofficeOperatorCreateRequestDTO( + bootstrapEmail, + bootstrapPassword, + bootstrapName + ) + ); + log.info("Initial BACKOFFICE operator bootstrapped for email={}", bootstrapEmail); + } +} From b08f115f46eb7a63939673fa3f653e6f7237982d Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:30:43 +0900 Subject: [PATCH 14/19] =?UTF-8?q?[FEAT/#359]=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EA=B3=84=EC=A0=95=20=EA=B4=80=EB=A6=AC=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=9C=20CRUD=20=EC=9E=91=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BackofficeOperatorController.java | 45 +++++++++++ .../BackofficeOperatorCreateRequestDTO.java | 24 ++++++ .../dto/BackofficeOperatorResponseDTO.java | 30 ++++++++ .../service/BackofficeOperatorService.java | 12 +++ .../BackofficeOperatorServiceImpl.java | 75 +++++++++++++++++++ 5 files changed, 186 insertions(+) create mode 100644 src/main/java/com/assu/server/domain/backoffice/controller/BackofficeOperatorController.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/dto/BackofficeOperatorCreateRequestDTO.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/dto/BackofficeOperatorResponseDTO.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/service/BackofficeOperatorService.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/service/BackofficeOperatorServiceImpl.java diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeOperatorController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeOperatorController.java new file mode 100644 index 00000000..e721b381 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeOperatorController.java @@ -0,0 +1,45 @@ +package com.assu.server.domain.backoffice.controller; + +import com.assu.server.domain.backoffice.annotation.BackofficeAudited; +import com.assu.server.domain.backoffice.dto.BackofficeOperatorCreateRequestDTO; +import com.assu.server.domain.backoffice.dto.BackofficeOperatorResponseDTO; +import com.assu.server.domain.backoffice.service.BackofficeOperatorService; +import com.assu.server.global.apiPayload.BaseResponse; +import com.assu.server.global.apiPayload.code.status.SuccessStatus; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@Tag(name = "Backoffice", description = "백오피스 운영 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("/backoffice/operators") +@PreAuthorize("hasRole('BACKOFFICE')") +public class BackofficeOperatorController { + + private final BackofficeOperatorService backofficeOperatorService; + + @BackofficeAudited(action = "OPERATOR_CREATE") + @Operation(summary = "백오피스 운영자 생성 API") + @PostMapping + public BaseResponse createOperator( + @RequestBody @Valid BackofficeOperatorCreateRequestDTO request + ) { + return BaseResponse.onSuccess(SuccessStatus._OK, backofficeOperatorService.createOperator(request)); + } + + @Operation(summary = "백오피스 운영자 목록 조회 API") + @GetMapping + public BaseResponse> listOperators() { + return BaseResponse.onSuccess(SuccessStatus._OK, backofficeOperatorService.listOperators()); + } +} diff --git a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeOperatorCreateRequestDTO.java b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeOperatorCreateRequestDTO.java new file mode 100644 index 00000000..98a96da9 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeOperatorCreateRequestDTO.java @@ -0,0 +1,24 @@ +package com.assu.server.domain.backoffice.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +@Schema(description = "백오피스 운영자 생성 요청") +public record BackofficeOperatorCreateRequestDTO( + @Schema(description = "이메일", example = "ops@assu.app") + @Email + @NotBlank + String email, + + @Schema(description = "비밀번호", example = "P@ssw0rd!") + @Size(min = 8, max = 72) + @NotBlank + String password, + + @Schema(description = "운영자 이름", example = "플랫폼 운영자") + @NotBlank + String name +) { +} diff --git a/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeOperatorResponseDTO.java b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeOperatorResponseDTO.java new file mode 100644 index 00000000..8464a2b1 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/dto/BackofficeOperatorResponseDTO.java @@ -0,0 +1,30 @@ +package com.assu.server.domain.backoffice.dto; + +import com.assu.server.domain.backoffice.entity.BackofficeUser; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.member.entity.Member; +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "백오피스 운영자 응답") +public record BackofficeOperatorResponseDTO( + @Schema(description = "회원 ID") + Long memberId, + + @Schema(description = "이메일") + String email, + + @Schema(description = "운영자 이름") + String name, + + @Schema(description = "활성 상태") + ActivationStatus status +) { + public static BackofficeOperatorResponseDTO from(Member member, BackofficeUser backofficeUser) { + return new BackofficeOperatorResponseDTO( + member.getId(), + member.getCommonAuth().getEmail(), + backofficeUser.getName(), + member.getIsActivated() + ); + } +} diff --git a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeOperatorService.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeOperatorService.java new file mode 100644 index 00000000..b95120ac --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeOperatorService.java @@ -0,0 +1,12 @@ +package com.assu.server.domain.backoffice.service; + +import com.assu.server.domain.backoffice.dto.BackofficeOperatorCreateRequestDTO; +import com.assu.server.domain.backoffice.dto.BackofficeOperatorResponseDTO; + +import java.util.List; + +public interface BackofficeOperatorService { + BackofficeOperatorResponseDTO createOperator(BackofficeOperatorCreateRequestDTO request); + + List listOperators(); +} diff --git a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeOperatorServiceImpl.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeOperatorServiceImpl.java new file mode 100644 index 00000000..4cdeb438 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeOperatorServiceImpl.java @@ -0,0 +1,75 @@ +package com.assu.server.domain.backoffice.service; + +import com.assu.server.domain.auth.entity.enums.AuthRealm; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.auth.repository.CommonAuthRepository; +import com.assu.server.domain.auth.security.adapter.RealmAuthAdapter; +import com.assu.server.domain.backoffice.dto.BackofficeOperatorCreateRequestDTO; +import com.assu.server.domain.backoffice.dto.BackofficeOperatorResponseDTO; +import com.assu.server.domain.backoffice.entity.BackofficeUser; +import com.assu.server.domain.backoffice.repository.BackofficeUserRepository; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class BackofficeOperatorServiceImpl implements BackofficeOperatorService { + + private final MemberRepository memberRepository; + private final BackofficeUserRepository backofficeUserRepository; + private final CommonAuthRepository commonAuthRepository; + private final List realmAuthAdapters; + + @Override + public BackofficeOperatorResponseDTO createOperator(BackofficeOperatorCreateRequestDTO request) { + if (commonAuthRepository.existsByEmail(request.email())) { + throw new CustomAuthException(ErrorStatus.EXISTED_EMAIL); + } + + Member member = memberRepository.save( + Member.builder() + .isLocationTermAgreed(true) + .isMarketingTermAgreed(false) + .role(UserRole.BACKOFFICE) + .isActivated(ActivationStatus.ACTIVE) + .build() + ); + + RealmAuthAdapter adapter = pickAdapter(AuthRealm.COMMON); + adapter.registerCredentials(member, request.email(), request.password()); + + BackofficeUser backofficeUser = backofficeUserRepository.save( + BackofficeUser.builder() + .member(member) + .name(request.name()) + .build() + ); + member.setProfile(backofficeUser); + + return BackofficeOperatorResponseDTO.from(member, backofficeUser); + } + + @Override + @Transactional(readOnly = true) + public List listOperators() { + return backofficeUserRepository.findAll().stream() + .map(user -> BackofficeOperatorResponseDTO.from(user.getMember(), user)) + .toList(); + } + + private RealmAuthAdapter pickAdapter(AuthRealm realm) { + return realmAuthAdapters.stream() + .filter(a -> a.supports(realm)) + .findFirst() + .orElseThrow(() -> new CustomAuthException(ErrorStatus.AUTHORIZATION_EXCEPTION)); + } +} From f7ea65f9dedc7d3f1294ee2f16ffba2cc8f2b65e Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:32:02 +0900 Subject: [PATCH 15/19] =?UTF-8?q?[FEAT/#359]=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=EA=B0=90=EC=82=AC=20=EB=A1=9C=EA=B7=B8=20=EC=B6=94?= =?UTF-8?q?=EC=A0=81=EC=9D=84=20=EC=9C=84=ED=95=9C=20AOP=20=EC=9E=91?= =?UTF-8?q?=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../annotation/BackofficeAudited.java | 13 +++ .../backoffice/aop/BackofficeAuditAspect.java | 110 ++++++++++++++++++ .../backoffice/entity/BackofficeAuditLog.java | 50 ++++++++ .../entity/enums/BackofficeAuditStatus.java | 5 + .../BackofficeAuditLogRepository.java | 7 ++ .../service/BackofficeAuditLogService.java | 55 +++++++++ 6 files changed, 240 insertions(+) create mode 100644 src/main/java/com/assu/server/domain/backoffice/annotation/BackofficeAudited.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/aop/BackofficeAuditAspect.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/entity/BackofficeAuditLog.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/entity/enums/BackofficeAuditStatus.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/repository/BackofficeAuditLogRepository.java create mode 100644 src/main/java/com/assu/server/domain/backoffice/service/BackofficeAuditLogService.java diff --git a/src/main/java/com/assu/server/domain/backoffice/annotation/BackofficeAudited.java b/src/main/java/com/assu/server/domain/backoffice/annotation/BackofficeAudited.java new file mode 100644 index 00000000..6120f27b --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/annotation/BackofficeAudited.java @@ -0,0 +1,13 @@ +package com.assu.server.domain.backoffice.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +public @interface BackofficeAudited { + String action(); + String targetId() default ""; +} diff --git a/src/main/java/com/assu/server/domain/backoffice/aop/BackofficeAuditAspect.java b/src/main/java/com/assu/server/domain/backoffice/aop/BackofficeAuditAspect.java new file mode 100644 index 00000000..d67243ad --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/aop/BackofficeAuditAspect.java @@ -0,0 +1,110 @@ +package com.assu.server.domain.backoffice.aop; + +import com.assu.server.domain.backoffice.annotation.BackofficeAudited; +import com.assu.server.domain.backoffice.entity.BackofficeAuditLog; +import com.assu.server.domain.backoffice.entity.enums.BackofficeAuditStatus; +import com.assu.server.domain.backoffice.service.BackofficeAuditLogService; +import com.assu.server.global.util.PrincipalDetails; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.annotation.Around; +import org.aspectj.lang.annotation.Aspect; +import org.aspectj.lang.reflect.MethodSignature; +import org.springframework.expression.ExpressionParser; +import org.springframework.expression.spel.standard.SpelExpressionParser; +import org.springframework.expression.spel.support.StandardEvaluationContext; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +@Aspect +@Component +@RequiredArgsConstructor +public class BackofficeAuditAspect { + + private final BackofficeAuditLogService backofficeAuditLogService; + private final ExpressionParser expressionParser = new SpelExpressionParser(); + + @Around("@annotation(backofficeAudited)") + public Object audit(ProceedingJoinPoint joinPoint, BackofficeAudited backofficeAudited) throws Throwable { + long startedAt = System.currentTimeMillis(); + HttpServletRequest request = currentRequest(); + Long backofficeMemberId = currentMemberId(); + String handler = joinPoint.getSignature().getDeclaringTypeName() + "." + joinPoint.getSignature().getName(); + String targetResourceId = resolveTargetId(joinPoint, backofficeAudited.targetId()); + + try { + Object result = joinPoint.proceed(); + backofficeAuditLogService.save(BackofficeAuditLog.builder() + .backofficeMemberId(backofficeMemberId) + .httpMethod(request != null ? request.getMethod() : "UNKNOWN") + .requestUri(request != null ? request.getRequestURI() : "UNKNOWN") + .handler(handler) + .action(backofficeAudited.action()) + .targetResourceId(targetResourceId) + .clientIp(resolveClientIp(request)) + .status(BackofficeAuditStatus.SUCCESS) + .httpStatusCode(200) + .durationMs(System.currentTimeMillis() - startedAt) + .build()); + return result; + } catch (Throwable throwable) { + backofficeAuditLogService.saveFailure( + backofficeMemberId, + request != null ? request.getMethod() : "UNKNOWN", + request != null ? request.getRequestURI() : "UNKNOWN", + handler, + backofficeAudited.action(), + targetResourceId, + resolveClientIp(request), + System.currentTimeMillis() - startedAt, + throwable.getMessage() + ); + throw throwable; + } + } + + private HttpServletRequest currentRequest() { + ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); + return attributes != null ? attributes.getRequest() : null; + } + + private Long currentMemberId() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.getPrincipal() instanceof PrincipalDetails pd) { + return pd.getId(); + } + return null; + } + + private String resolveTargetId(ProceedingJoinPoint joinPoint, String spel) { + if (spel == null || spel.isBlank()) { + return null; + } + MethodSignature signature = (MethodSignature) joinPoint.getSignature(); + StandardEvaluationContext context = new StandardEvaluationContext(); + String[] parameterNames = signature.getParameterNames(); + Object[] args = joinPoint.getArgs(); + if (parameterNames != null) { + for (int i = 0; i < parameterNames.length; i++) { + context.setVariable(parameterNames[i], args[i]); + } + } + Object value = expressionParser.parseExpression(spel).getValue(context); + return value != null ? value.toString() : null; + } + + private String resolveClientIp(HttpServletRequest request) { + if (request == null) { + return null; + } + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/com/assu/server/domain/backoffice/entity/BackofficeAuditLog.java b/src/main/java/com/assu/server/domain/backoffice/entity/BackofficeAuditLog.java new file mode 100644 index 00000000..b5026c6c --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/entity/BackofficeAuditLog.java @@ -0,0 +1,50 @@ +package com.assu.server.domain.backoffice.entity; + +import com.assu.server.domain.backoffice.entity.enums.BackofficeAuditStatus; +import com.assu.server.domain.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +@Entity +@Table(name = "backoffice_audit_log") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class BackofficeAuditLog extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + private Long backofficeMemberId; + + @Column(nullable = false, length = 16) + private String httpMethod; + + @Column(nullable = false, length = 512) + private String requestUri; + + @Column(nullable = false, length = 255) + private String handler; + + @Column(length = 64) + private String action; + + @Column(length = 128) + private String targetResourceId; + + @Column(length = 64) + private String clientIp; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private BackofficeAuditStatus status; + + private Integer httpStatusCode; + + private Long durationMs; + + @Column(length = 500) + private String errorMessage; +} diff --git a/src/main/java/com/assu/server/domain/backoffice/entity/enums/BackofficeAuditStatus.java b/src/main/java/com/assu/server/domain/backoffice/entity/enums/BackofficeAuditStatus.java new file mode 100644 index 00000000..03eb1a16 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/entity/enums/BackofficeAuditStatus.java @@ -0,0 +1,5 @@ +package com.assu.server.domain.backoffice.entity.enums; + +public enum BackofficeAuditStatus { + SUCCESS, FAILURE +} diff --git a/src/main/java/com/assu/server/domain/backoffice/repository/BackofficeAuditLogRepository.java b/src/main/java/com/assu/server/domain/backoffice/repository/BackofficeAuditLogRepository.java new file mode 100644 index 00000000..484eee2e --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/repository/BackofficeAuditLogRepository.java @@ -0,0 +1,7 @@ +package com.assu.server.domain.backoffice.repository; + +import com.assu.server.domain.backoffice.entity.BackofficeAuditLog; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface BackofficeAuditLogRepository extends JpaRepository { +} diff --git a/src/main/java/com/assu/server/domain/backoffice/service/BackofficeAuditLogService.java b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeAuditLogService.java new file mode 100644 index 00000000..0566f640 --- /dev/null +++ b/src/main/java/com/assu/server/domain/backoffice/service/BackofficeAuditLogService.java @@ -0,0 +1,55 @@ +package com.assu.server.domain.backoffice.service; + +import com.assu.server.domain.backoffice.entity.BackofficeAuditLog; +import com.assu.server.domain.backoffice.entity.enums.BackofficeAuditStatus; +import com.assu.server.domain.backoffice.repository.BackofficeAuditLogRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class BackofficeAuditLogService { + + private final BackofficeAuditLogRepository backofficeAuditLogRepository; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void save(BackofficeAuditLog log) { + backofficeAuditLogRepository.save(log); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void saveFailure( + Long backofficeMemberId, + String httpMethod, + String requestUri, + String handler, + String action, + String targetResourceId, + String clientIp, + long durationMs, + String errorMessage + ) { + save(BackofficeAuditLog.builder() + .backofficeMemberId(backofficeMemberId) + .httpMethod(httpMethod) + .requestUri(requestUri) + .handler(handler) + .action(action) + .targetResourceId(targetResourceId) + .clientIp(clientIp) + .status(BackofficeAuditStatus.FAILURE) + .httpStatusCode(500) + .durationMs(durationMs) + .errorMessage(truncate(errorMessage)) + .build()); + } + + static String truncate(String message) { + if (message == null) { + return null; + } + return message.length() <= 500 ? message : message.substring(0, 500); + } +} From 87333ba6bfe27044b6c39f4a2345c4c940932c48 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:32:45 +0900 Subject: [PATCH 16/19] =?UTF-8?q?[TEST/#359]=20=EB=B0=B1=EC=98=A4=ED=94=BC?= =?UTF-8?q?=EC=8A=A4=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../backoffice/BackofficeAuditAspectTest.java | 121 ++++++++ .../BackofficeSecurityIntegrationTest.java | 279 ++++++++++++++++++ 2 files changed, 400 insertions(+) create mode 100644 src/test/java/com/assu/server/backoffice/BackofficeAuditAspectTest.java create mode 100644 src/test/java/com/assu/server/backoffice/BackofficeSecurityIntegrationTest.java diff --git a/src/test/java/com/assu/server/backoffice/BackofficeAuditAspectTest.java b/src/test/java/com/assu/server/backoffice/BackofficeAuditAspectTest.java new file mode 100644 index 00000000..17a23881 --- /dev/null +++ b/src/test/java/com/assu/server/backoffice/BackofficeAuditAspectTest.java @@ -0,0 +1,121 @@ +package com.assu.server.backoffice; + +import com.assu.server.domain.backoffice.aop.BackofficeAuditAspect; +import com.assu.server.domain.backoffice.entity.BackofficeAuditLog; +import com.assu.server.domain.backoffice.entity.enums.BackofficeAuditStatus; +import com.assu.server.domain.backoffice.service.BackofficeAuditLogService; +import org.aspectj.lang.ProceedingJoinPoint; +import org.aspectj.lang.reflect.MethodSignature; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class BackofficeAuditAspectTest { + + @InjectMocks + private BackofficeAuditAspect backofficeAuditAspect; + + @Mock + private BackofficeAuditLogService backofficeAuditLogService; + + @Mock + private ProceedingJoinPoint joinPoint; + + @Mock + private MethodSignature methodSignature; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + RequestContextHolder.resetRequestAttributes(); + } + + @Test + @DisplayName("성공 시 SUCCESS 감사 로그를 저장한다") + void savesSuccessAuditLog() throws Throwable { + MockHttpServletRequest request = new MockHttpServletRequest("POST", "/backoffice/students/sync"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + SecurityContextHolder.getContext().setAuthentication( + new UsernamePasswordAuthenticationToken("1", null) + ); + + when(joinPoint.getSignature()).thenReturn(methodSignature); + when(methodSignature.getDeclaringTypeName()).thenReturn("com.assu.server.domain.backoffice.controller.BackofficeStudentController"); + when(methodSignature.getName()).thenReturn("syncAllStudentsNow"); + when(joinPoint.proceed()).thenReturn("ok"); + + var annotation = TestController.class.getDeclaredMethod("sync").getAnnotation( + com.assu.server.domain.backoffice.annotation.BackofficeAudited.class + ); + + Object result = backofficeAuditAspect.audit(joinPoint, annotation); + + assertThat(result).isEqualTo("ok"); + ArgumentCaptor captor = ArgumentCaptor.forClass(BackofficeAuditLog.class); + verify(backofficeAuditLogService).save(captor.capture()); + BackofficeAuditLog saved = captor.getValue(); + assertThat(saved.getAction()).isEqualTo("STUDENT_SYNC"); + assertThat(saved.getStatus()).isEqualTo(BackofficeAuditStatus.SUCCESS); + assertThat(saved.getHttpMethod()).isEqualTo("POST"); + assertThat(saved.getRequestUri()).isEqualTo("/backoffice/students/sync"); + } + + @Test + @DisplayName("실패 시 FAILURE 감사 로그를 저장하고 예외를 재던진다") + void savesFailureAuditLogAndRethrows() throws Throwable { + MockHttpServletRequest request = new MockHttpServletRequest("PATCH", "/backoffice/inquiries/10/answer"); + RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); + + when(joinPoint.getSignature()).thenReturn(methodSignature); + when(methodSignature.getDeclaringTypeName()).thenReturn("com.assu.server.domain.backoffice.controller.BackofficeInquiryController"); + when(methodSignature.getName()).thenReturn("answerInquiry"); + when(joinPoint.proceed()).thenThrow(new IllegalStateException("boom")); + + var annotation = TestController.class.getDeclaredMethod("answer").getAnnotation( + com.assu.server.domain.backoffice.annotation.BackofficeAudited.class + ); + + assertThatThrownBy(() -> backofficeAuditAspect.audit(joinPoint, annotation)) + .isInstanceOf(IllegalStateException.class) + .hasMessage("boom"); + + verify(backofficeAuditLogService).saveFailure( + any(), + org.mockito.ArgumentMatchers.eq("PATCH"), + org.mockito.ArgumentMatchers.eq("/backoffice/inquiries/10/answer"), + org.mockito.ArgumentMatchers.contains("BackofficeInquiryController.answerInquiry"), + org.mockito.ArgumentMatchers.eq("INQUIRY_ANSWER"), + any(), + any(), + org.mockito.ArgumentMatchers.anyLong(), + org.mockito.ArgumentMatchers.eq("boom") + ); + } + + static class TestController { + @com.assu.server.domain.backoffice.annotation.BackofficeAudited(action = "STUDENT_SYNC") + void sync() { + } + + @com.assu.server.domain.backoffice.annotation.BackofficeAudited(action = "INQUIRY_ANSWER", targetId = "#inquiryId") + void answer() { + } + } +} diff --git a/src/test/java/com/assu/server/backoffice/BackofficeSecurityIntegrationTest.java b/src/test/java/com/assu/server/backoffice/BackofficeSecurityIntegrationTest.java new file mode 100644 index 00000000..eceffb41 --- /dev/null +++ b/src/test/java/com/assu/server/backoffice/BackofficeSecurityIntegrationTest.java @@ -0,0 +1,279 @@ +package com.assu.server.backoffice; + +import com.assu.server.domain.auth.entity.CommonAuth; +import com.assu.server.domain.auth.entity.enums.AuthRealm; +import com.assu.server.domain.auth.exception.CustomAuthException; +import com.assu.server.domain.auth.repository.CommonAuthRepository; +import com.assu.server.domain.auth.security.jwt.JwtUtil; +import com.assu.server.global.apiPayload.code.status.ErrorStatus; +import com.assu.server.domain.admin.entity.Admin; +import com.assu.server.domain.backoffice.entity.BackofficeUser; +import com.assu.server.domain.backoffice.repository.BackofficeAuditLogRepository; +import com.assu.server.domain.backoffice.repository.BackofficeUserRepository; +import com.assu.server.domain.common.entity.enums.University; +import com.assu.server.domain.common.enums.ActivationStatus; +import com.assu.server.domain.common.enums.UserRole; +import com.assu.server.domain.member.entity.Member; +import com.assu.server.domain.member.repository.MemberRepository; +import com.assu.server.domain.student.entity.Student; +import com.assu.server.domain.student.service.StudentServiceImpl; +import com.google.firebase.messaging.FirebaseMessaging; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.amqp.rabbit.connection.ConnectionFactory; +import org.springframework.amqp.rabbit.core.RabbitTemplate; +import org.springframework.amqp.rabbit.listener.RabbitListenerContainerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Primary; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.core.ValueOperations; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Transactional +class BackofficeSecurityIntegrationTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtUtil jwtUtil; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private CommonAuthRepository commonAuthRepository; + + @Autowired + private BackofficeUserRepository backofficeUserRepository; + + @Autowired + private BackofficeAuditLogRepository backofficeAuditLogRepository; + + @MockitoBean + private StudentServiceImpl studentService; + + @MockitoBean + private ConnectionFactory connectionFactory; + + @MockitoBean + private RabbitTemplate rabbitTemplate; + + @BeforeEach + void setUp() { + backofficeAuditLogRepository.deleteAll(); + } + + @Test + @DisplayName("BACKOFFICE 토큰으로 /backoffice/students/sync 접근 가능") + void backofficeTokenCanAccessSyncEndpoint() throws Exception { + Member backofficeMember = createBackofficeMember("backoffice@test.com", "Operator"); + String accessToken = jwtUtil.issueBackofficeTokens( + backofficeMember.getId(), + "backoffice@test.com", + UserRole.BACKOFFICE, + AuthRealm.COMMON.name() + ).accessToken(); + + mockMvc.perform(post("/backoffice/students/sync") + .header("Authorization", "Bearer " + accessToken)) + .andExpect(status().isOk()); + + verify(studentService).syncUserPapersForAllStudents(); + assertThat(backofficeAuditLogRepository.findAll()) + .singleElement() + .satisfies(log -> { + assertThat(log.getAction()).isEqualTo("STUDENT_SYNC"); + assertThat(log.getBackofficeMemberId()).isEqualTo(backofficeMember.getId()); + assertThat(log.getRequestUri()).isEqualTo("/backoffice/students/sync"); + }); + } + + @Test + @DisplayName("STUDENT 앱 토큰으로 /backoffice/students/sync 접근 거부") + void studentTokenCannotAccessBackofficeEndpoint() throws Exception { + Member student = createStudentMember("student@test.com"); + String accessToken = jwtUtil.issueTokens( + student.getId(), + "student@test.com", + UserRole.STUDENT, + AuthRealm.COMMON.name() + ).accessToken(); + + assertThatThrownBy(() -> mockMvc.perform(post("/backoffice/students/sync") + .header("Authorization", "Bearer " + accessToken))) + .isInstanceOf(CustomAuthException.class) + .extracting(ex -> ((CustomAuthException) ex).getCode()) + .isEqualTo(ErrorStatus.JWT_AUDIENCE_MISMATCH); + + assertThat(backofficeAuditLogRepository.findAll()).isEmpty(); + } + + @Test + @DisplayName("ADMIN 앱 토큰으로 /backoffice/students/sync 접근 거부") + void adminTokenCannotAccessBackofficeEndpoint() throws Exception { + Member admin = createAdminMember("admin@test.com"); + String accessToken = jwtUtil.issueTokens( + admin.getId(), + "admin@test.com", + UserRole.ADMIN, + AuthRealm.COMMON.name() + ).accessToken(); + + assertThatThrownBy(() -> mockMvc.perform(post("/backoffice/students/sync") + .header("Authorization", "Bearer " + accessToken))) + .isInstanceOf(CustomAuthException.class) + .extracting(ex -> ((CustomAuthException) ex).getCode()) + .isEqualTo(ErrorStatus.JWT_AUDIENCE_MISMATCH); + + assertThat(backofficeAuditLogRepository.findAll()).isEmpty(); + } + + private Member createBackofficeMember(String email, String name) { + Member member = memberRepository.save(Member.builder() + .role(UserRole.BACKOFFICE) + .isActivated(ActivationStatus.ACTIVE) + .isLocationTermAgreed(true) + .isMarketingTermAgreed(false) + .build()); + + commonAuthRepository.save(CommonAuth.builder() + .member(member) + .email(email) + .hashedPassword("hashed-password") + .lastLoginAt(LocalDateTime.now()) + .build()); + + backofficeUserRepository.save(BackofficeUser.builder() + .member(member) + .name(name) + .build()); + + return memberRepository.findById(member.getId()).orElseThrow(); + } + + private Member createStudentMember(String email) { + Member member = memberRepository.save(Member.builder() + .role(UserRole.STUDENT) + .isActivated(ActivationStatus.ACTIVE) + .isLocationTermAgreed(true) + .isMarketingTermAgreed(false) + .build()); + + commonAuthRepository.save(CommonAuth.builder() + .member(member) + .email(email) + .hashedPassword("hashed-password") + .lastLoginAt(LocalDateTime.now()) + .build()); + + member.setStudentProfile(Student.builder() + .member(member) + .name("Test Student") + .build()); + + return memberRepository.save(member); + } + + private Member createAdminMember(String email) { + Member member = memberRepository.save(Member.builder() + .role(UserRole.ADMIN) + .isActivated(ActivationStatus.ACTIVE) + .isLocationTermAgreed(true) + .isMarketingTermAgreed(false) + .build()); + + commonAuthRepository.save(CommonAuth.builder() + .member(member) + .email(email) + .hashedPassword("hashed-password") + .lastLoginAt(LocalDateTime.now()) + .build()); + + member.setAdminProfile(Admin.builder() + .member(member) + .name("Test Admin") + .isPhoneVerified(false) + .officeAddress("Test Office") + .university(University.SSU) + .build()); + + return memberRepository.save(member); + } + + @TestConfiguration + static class TestJwtConfig { + + @Bean + FirebaseMessaging firebaseMessaging() { + return Mockito.mock(FirebaseMessaging.class); + } + + @Bean + RedisConnectionFactory redisConnectionFactory() { + RedisConnectionFactory connectionFactory = Mockito.mock(RedisConnectionFactory.class); + RedisConnection connection = Mockito.mock(RedisConnection.class); + Mockito.when(connectionFactory.getConnection()).thenReturn(connection); + return connectionFactory; + } + + @Bean + @SuppressWarnings("unchecked") + RedisTemplate redisTemplate() { + return Mockito.mock(RedisTemplate.class); + } + + @Bean + StringRedisTemplate stringRedisTemplate() { + return Mockito.mock(StringRedisTemplate.class); + } + + @Bean(name = "rabbitListenerContainerFactory") + RabbitListenerContainerFactory rabbitListenerContainerFactory() { + var factory = Mockito.mock(RabbitListenerContainerFactory.class); + var container = Mockito.mock(org.springframework.amqp.rabbit.listener.MessageListenerContainer.class); + Mockito.when(factory.createListenerContainer(Mockito.any())).thenReturn(container); + return factory; + } + + @Bean + @Primary + JwtUtil jwtUtil(MemberRepository memberRepository, StringRedisTemplate stringRedisTemplate, RedisConnectionFactory redisConnectionFactory) { + ValueOperations valueOperations = Mockito.mock(ValueOperations.class); + Mockito.when(stringRedisTemplate.opsForValue()).thenReturn(valueOperations); + Mockito.when(stringRedisTemplate.hasKey(Mockito.anyString())).thenReturn(false); + Mockito.when(stringRedisTemplate.getConnectionFactory()).thenReturn(redisConnectionFactory); + + JwtUtil jwtUtil = new JwtUtil(memberRepository, stringRedisTemplate); + ReflectionTestUtils.setField(jwtUtil, "secretKey", "S3csfifR3TrgwiKeyM2023WClokeyAppWIFNEGIBKWMGJ"); + ReflectionTestUtils.setField(jwtUtil, "accessValidSeconds", 3600); + ReflectionTestUtils.setField(jwtUtil, "backofficeAccessValidSeconds", 1800); + ReflectionTestUtils.setField(jwtUtil, "refreshValidSeconds", 1209600); + return jwtUtil; + } + } +} From c5c8e8ab9d6cfa72525cdef35484464a61e1f79b Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:42:07 +0900 Subject: [PATCH 17/19] =?UTF-8?q?[TEST/#359]=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/inquiry/controller/InquiryController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java b/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java index f3e9b383..4d660bc0 100644 --- a/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java +++ b/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java @@ -25,7 +25,7 @@ @Validated @RequestMapping("/inquiries") @RequiredArgsConstructor -@PreAuthorize("hasRole('STUDENTS')") +@PreAuthorize("hasRole('STUDENT')") public class InquiryController { private final InquiryService inquiryService; From af5d78f9b569eca9c85d49e99e0ea69de73933be Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:46:33 +0900 Subject: [PATCH 18/19] =?UTF-8?q?[TEST/#359]=20=EC=8A=A4=EC=9B=A8=EA=B1=B0?= =?UTF-8?q?=20=EC=BB=A8=EB=B2=A4=EC=85=98=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/BackofficeAuthController.java | 73 +++++++++++++++++-- .../BackofficeInquiryController.java | 18 ++++- .../BackofficeOperatorController.java | 35 ++++++++- .../BackofficeStudentController.java | 16 +++- 4 files changed, 133 insertions(+), 9 deletions(-) diff --git a/src/main/java/com/assu/server/domain/auth/controller/BackofficeAuthController.java b/src/main/java/com/assu/server/domain/auth/controller/BackofficeAuthController.java index 4a62bb6c..b02044bd 100644 --- a/src/main/java/com/assu/server/domain/auth/controller/BackofficeAuthController.java +++ b/src/main/java/com/assu/server/domain/auth/controller/BackofficeAuthController.java @@ -1,12 +1,16 @@ package com.assu.server.domain.auth.controller; +import com.assu.server.domain.auth.dto.backoffice.BackofficeLoginResponseDTO; import com.assu.server.domain.auth.dto.login.CommonLoginRequestDTO; import com.assu.server.domain.auth.dto.login.RefreshResponseDTO; -import com.assu.server.domain.auth.dto.backoffice.BackofficeLoginResponseDTO; import com.assu.server.domain.auth.service.BackofficeAuthService; import com.assu.server.global.apiPayload.BaseResponse; import com.assu.server.global.apiPayload.code.status.SuccessStatus; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -25,15 +29,74 @@ public class BackofficeAuthController { private final BackofficeAuthService backofficeAuthService; - @Operation(summary = "백오피스 로그인 API", description = "BACKOFFICE 역할 계정 전용 로그인. aud=backoffice JWT 발급.") + @Operation( + summary = "백오피스 로그인 API", + description = "# [v1.0 (2026-06-23)]\n" + + "- `application/json`으로 호출합니다.\n" + + "- 바디: `CommonLoginRequestDTO(email, password)`.\n" + + "- `BACKOFFICE` 역할 계정만 로그인할 수 있습니다.\n" + + "- 처리: 자격 증명 검증 후 `aud=backoffice` Access/Refresh 토큰 발급 및 저장.\n" + + "- 성공 시 200(OK)과 토큰(accessToken/refreshToken), 기본 정보 반환.\n" + + "\n**Request Body:**\n" + + " - `CommonLoginRequestDTO` 객체 (JSON, required): 로그인 정보\n" + + " - `email` (String, required): 이메일 주소\n" + + " - `password` (String, required): 비밀번호\n" + + "\n**Response:**\n" + + " - 성공 시 200(OK)과 `BackofficeLoginResponseDTO` 객체 반환\n" + + " - `memberId` (Long): 회원 ID\n" + + " - `role` (UserRole): BACKOFFICE\n" + + " - `status` (ActivationStatus): 회원 상태\n" + + " - `tokens` (Object): JWT 토큰 정보 (accessToken, refreshToken)\n" + + " - `basicInfo` (UserBasicInfo): 운영자 이름 등 기본 정보\n" + + " - 403(FORBIDDEN): BACKOFFICE 역할이 아닌 계정\n" + + " - 401(UNAUTHORIZED): 이메일 또는 비밀번호 불일치" + ) @PostMapping(value = "/login", consumes = MediaType.APPLICATION_JSON_VALUE) - public BaseResponse login(@RequestBody @Valid CommonLoginRequestDTO request) { + public BaseResponse login( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + required = true, + description = "백오피스 로그인 요청", + content = @Content( + mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = CommonLoginRequestDTO.class) + ) + ) + @RequestBody + @Valid + CommonLoginRequestDTO request + ) { return BaseResponse.onSuccess(SuccessStatus._OK, backofficeAuthService.login(request)); } - @Operation(summary = "백오피스 Access Token 갱신 API", description = "aud=backoffice refresh token 전용.") + @Operation( + summary = "백오피스 Access Token 갱신 API", + description = "# [v1.0 (2026-06-23)]\n" + + "- 헤더로 호출합니다.\n" + + "- 헤더: `RefreshToken: `.\n" + + "- `aud=backoffice` Refresh Token만 갱신할 수 있습니다.\n" + + "- 처리: Refresh 검증/회전 후 신규 Access/Refresh 발급 및 저장.\n" + + "- 성공 시 200(OK)과 신규 토큰 반환.\n" + + "\n**Headers:**\n" + + " - `RefreshToken` (String, required): 백오피스 리프레시 토큰\n" + + "\n**Response:**\n" + + " - 성공 시 200(OK)과 `RefreshResponseDTO` 객체 반환\n" + + " - `memberId` (Long): 회원 ID\n" + + " - `newAccess` (String): 새로운 액세스 토큰\n" + + " - `newRefresh` (String): 새로운 리프레시 토큰\n" + + " - 401(UNAUTHORIZED): audience 불일치 또는 유효하지 않은 Refresh Token" + ) @PostMapping("/tokens/refresh") - public BaseResponse refresh(@RequestHeader("RefreshToken") String refreshToken) { + public BaseResponse refresh( + @Parameter( + name = "RefreshToken", + description = "Backoffice Refresh Token", + required = true, + in = ParameterIn.HEADER, + schema = @Schema(type = "string") + ) + @RequestHeader("RefreshToken") + String refreshToken + ) { return BaseResponse.onSuccess(SuccessStatus._OK, backofficeAuthService.refresh(refreshToken)); } } diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeInquiryController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeInquiryController.java index 98fd2c62..207a010b 100644 --- a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeInquiryController.java +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeInquiryController.java @@ -26,7 +26,23 @@ public class BackofficeInquiryController { private final InquiryService inquiryService; @BackofficeAudited(action = "INQUIRY_ANSWER", targetId = "#inquiryId") - @Operation(summary = "운영자 문의 답변 API") + @Operation( + summary = "운영자 문의 답변 API", + description = "# [v1.0 (2025-09-02)](https://www.notion.so/24e1197c19ed8064808fcca568b8912a?source=copy_link)\n" + + "- 문의에 답변을 등록하고 상태를 ANSWERED로 변경합니다.\n" + + "- `BACKOFFICE` 역할 및 `aud=backoffice` JWT가 필요합니다.\n\n" + + "**Path Variable:**\n" + + "- `inquiryId` (Long, required): 문의 ID\n\n" + + "**Request Body:**\n" + + "- `answer` (String, required): 답변 내용\n\n" + + "**Response:**\n" + + "- 성공 시 200(OK)과 성공 메시지 반환\n" + + "- 400(BAD_REQUEST): 빈 답변 내용\n" + + "- 401(UNAUTHORIZED): 인증되지 않았거나 audience 불일치\n" + + "- 403(FORBIDDEN): BACKOFFICE 권한 없음\n" + + "- 404(NOT_FOUND): 존재하지 않는 문의 ID\n" + + "- 409(CONFLICT): 이미 답변된 문의" + ) @PatchMapping("/{inquiryId}/answer") public BaseResponse answer( @PathVariable("inquiryId") Long inquiryId, diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeOperatorController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeOperatorController.java index e721b381..4cb0dfa1 100644 --- a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeOperatorController.java +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeOperatorController.java @@ -29,7 +29,25 @@ public class BackofficeOperatorController { private final BackofficeOperatorService backofficeOperatorService; @BackofficeAudited(action = "OPERATOR_CREATE") - @Operation(summary = "백오피스 운영자 생성 API") + @Operation( + summary = "백오피스 운영자 생성 API", + description = "# [v1.0 (2026-06-23)]\n" + + "- 새로운 `BACKOFFICE` 운영자 계정을 생성합니다.\n" + + "- `BACKOFFICE` 역할 및 `aud=backoffice` JWT가 필요합니다.\n\n" + + "**Request Body:**\n" + + "- `email` (String, required): 로그인 이메일\n" + + "- `password` (String, required): 비밀번호 (8~72자)\n" + + "- `name` (String, required): 운영자 이름\n\n" + + "**Response:**\n" + + "- 성공 시 200(OK)과 `BackofficeOperatorResponseDTO` 반환\n" + + " - `memberId` (Long): 회원 ID\n" + + " - `email` (String): 이메일\n" + + " - `name` (String): 운영자 이름\n" + + " - `status` (ActivationStatus): 활성 상태\n" + + "- 401(UNAUTHORIZED): 인증되지 않았거나 audience 불일치\n" + + "- 403(FORBIDDEN): BACKOFFICE 권한 없음\n" + + "- 409(CONFLICT): 이미 사용 중인 이메일" + ) @PostMapping public BaseResponse createOperator( @RequestBody @Valid BackofficeOperatorCreateRequestDTO request @@ -37,7 +55,20 @@ public BaseResponse createOperator( return BaseResponse.onSuccess(SuccessStatus._OK, backofficeOperatorService.createOperator(request)); } - @Operation(summary = "백오피스 운영자 목록 조회 API") + @Operation( + summary = "백오피스 운영자 목록 조회 API", + description = "# [v1.0 (2026-06-23)]\n" + + "- 등록된 `BACKOFFICE` 운영자 목록을 조회합니다.\n" + + "- `BACKOFFICE` 역할 및 `aud=backoffice` JWT가 필요합니다.\n\n" + + "**Response:**\n" + + "- 성공 시 200(OK)과 `BackofficeOperatorResponseDTO` 목록 반환\n" + + " - `memberId` (Long): 회원 ID\n" + + " - `email` (String): 이메일\n" + + " - `name` (String): 운영자 이름\n" + + " - `status` (ActivationStatus): 활성 상태\n" + + "- 401(UNAUTHORIZED): 인증되지 않았거나 audience 불일치\n" + + "- 403(FORBIDDEN): BACKOFFICE 권한 없음" + ) @GetMapping public BaseResponse> listOperators() { return BaseResponse.onSuccess(SuccessStatus._OK, backofficeOperatorService.listOperators()); diff --git a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeStudentController.java b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeStudentController.java index 0bd4bfd1..d668cf70 100644 --- a/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeStudentController.java +++ b/src/main/java/com/assu/server/domain/backoffice/controller/BackofficeStudentController.java @@ -22,7 +22,21 @@ public class BackofficeStudentController { private final StudentService studentService; @BackofficeAudited(action = "STUDENT_SYNC") - @Operation(summary = "전체 학생 user_paper 동기화 API") + @Operation( + summary = "전체 학생 user_paper 동기화 API", + description = "# [v1.0 (2026-03-16)](https://clumsy-seeder-416.notion.site/3251197c19ed8066885cece9ffc455f6?source=copy_link)\n" + + "- 모든 학생의 user_paper 데이터를 동기화합니다.\n" + + "- `BACKOFFICE` 역할 및 `aud=backoffice` JWT가 필요합니다.\n" + + "- 시스템 전체에 영향을 주는 작업이므로 주의해서 사용해야 합니다.\n\n" + + "**주의사항:**\n" + + "- 대량의 데이터 처리로 인해 시간이 오래 걸릴 수 있음\n" + + "- 실행 중에는 다른 제휴 관련 작업에 영향을 줄 수 있음\n\n" + + "**Response:**\n" + + "- 성공 시 200(OK)과 동기화 완료 메시지 반환\n" + + "- 401(UNAUTHORIZED): 인증되지 않았거나 audience 불일치\n" + + "- 403(FORBIDDEN): BACKOFFICE 권한 없음\n" + + "- 500(INTERNAL_SERVER_ERROR): 동기화 작업 실패" + ) @PostMapping("/sync") public BaseResponse syncAllStudentsNow() { studentService.syncUserPapersForAllStudents(); From fd3f8c5b1ff6823a1246924b2b920ad0d68d542b Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 23 Jun 2026 02:47:30 +0900 Subject: [PATCH 19/19] =?UTF-8?q?[FIX/#359]=20=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../server/domain/inquiry/controller/InquiryController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java b/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java index f3e9b383..98855f06 100644 --- a/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java +++ b/src/main/java/com/assu/server/domain/inquiry/controller/InquiryController.java @@ -25,7 +25,7 @@ @Validated @RequestMapping("/inquiries") @RequiredArgsConstructor -@PreAuthorize("hasRole('STUDENTS')") +@PreAuthorize("hasRole('STUDENT')") public class InquiryController { private final InquiryService inquiryService; @@ -96,4 +96,4 @@ public BaseResponse get( InquiryResponseDTO inquiryResponseDTO = inquiryService.get(inquiryId, pd.getId()); return BaseResponse.onSuccess(SuccessStatus._OK, inquiryResponseDTO); } -} \ No newline at end of file +}