diff --git a/build.gradle b/build.gradle index 0d41958..12cb952 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,15 @@ dependencies { // Validation implementation 'org.springframework.boot:spring-boot-starter-validation' + + // Security + implementation 'org.springframework.boot:spring-boot-starter-security' + testImplementation 'org.springframework.security:spring-security-test' + // Jwt + implementation 'io.jsonwebtoken:jjwt-api:0.12.3' + implementation 'io.jsonwebtoken:jjwt-impl:0.12.3' + implementation 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'org.springframework.boot:spring-boot-configuration-processor' } tasks.named('test') { diff --git a/src/main/java/com/example/umc_9th/domain/member/controller/MemberController.java b/src/main/java/com/example/umc_9th/domain/member/controller/MemberController.java index 2ac3916..46312c8 100644 --- a/src/main/java/com/example/umc_9th/domain/member/controller/MemberController.java +++ b/src/main/java/com/example/umc_9th/domain/member/controller/MemberController.java @@ -38,6 +38,14 @@ public ApiResponse signUp( ){ return ApiResponse.onSuccess(MemberSuccessCode.FOUND, memberCommandService.signup(dto)); } + + // 로그인 + @PostMapping("/login") + public ApiResponse login( + @RequestBody @Valid MemberReqDTO.LoginDTO dto + ){ + return ApiResponse.onSuccess(MemberSuccessCode.FOUND, memberQueryService.login(dto)); + } } diff --git a/src/main/java/com/example/umc_9th/domain/member/converter/MemberConverter.java b/src/main/java/com/example/umc_9th/domain/member/converter/MemberConverter.java index 7410c1a..98a7648 100644 --- a/src/main/java/com/example/umc_9th/domain/member/converter/MemberConverter.java +++ b/src/main/java/com/example/umc_9th/domain/member/converter/MemberConverter.java @@ -5,6 +5,7 @@ import com.example.umc_9th.domain.member.dto.MemberResDTO; import com.example.umc_9th.domain.member.dto.MyPageDto; import com.example.umc_9th.domain.member.entity.Member; +import com.example.umc_9th.global.auth.enums.Role; public class MemberConverter { @@ -28,14 +29,26 @@ public static MemberResDTO.JoinDTO toJoinDTO( // DTO -> Entity public static Member toMember( - MemberReqDTO.JoinDTO dto + MemberReqDTO.JoinDTO dto, + String password, + Role role ){ return Member.builder() .name(dto.name()) + .email(dto.email()) + .password(password) + .role(role) .birth(dto.birth()) .address(dto.address()) .gender(dto.gender()) .build(); } + public static MemberResDTO.LoginDTO toLoginDTO(Member member, String accessToken) { + return MemberResDTO.LoginDTO.builder() + .memberId(member.getId()) + .accessToken(accessToken) + .build(); + } + } diff --git a/src/main/java/com/example/umc_9th/domain/member/dto/MemberReqDTO.java b/src/main/java/com/example/umc_9th/domain/member/dto/MemberReqDTO.java index 7e92082..5cc15c7 100644 --- a/src/main/java/com/example/umc_9th/domain/member/dto/MemberReqDTO.java +++ b/src/main/java/com/example/umc_9th/domain/member/dto/MemberReqDTO.java @@ -2,6 +2,7 @@ import com.example.umc_9th.domain.member.enums.Gender; import com.example.umc_9th.global.annotation.ExistFoods; +import jakarta.validation.constraints.NotBlank; import java.time.LocalDate; import java.util.List; @@ -9,6 +10,8 @@ public class MemberReqDTO { public record JoinDTO( String name, + String email, + String password, Gender gender, LocalDate birth, String address, @@ -17,4 +20,11 @@ public record JoinDTO( List preferCategory ){} + public record LoginDTO( + @NotBlank + String email, + @NotBlank + String password + ){} + } diff --git a/src/main/java/com/example/umc_9th/domain/member/dto/MemberResDTO.java b/src/main/java/com/example/umc_9th/domain/member/dto/MemberResDTO.java index f5cb293..509f9e8 100644 --- a/src/main/java/com/example/umc_9th/domain/member/dto/MemberResDTO.java +++ b/src/main/java/com/example/umc_9th/domain/member/dto/MemberResDTO.java @@ -10,4 +10,10 @@ public record JoinDTO( Long memberId, LocalDateTime createAt ){} + + @Builder + public record LoginDTO( + Long memberId, + String accessToken + ){} } diff --git a/src/main/java/com/example/umc_9th/domain/member/entity/Member.java b/src/main/java/com/example/umc_9th/domain/member/entity/Member.java index b47a9d1..12bd425 100644 --- a/src/main/java/com/example/umc_9th/domain/member/entity/Member.java +++ b/src/main/java/com/example/umc_9th/domain/member/entity/Member.java @@ -1,6 +1,7 @@ package com.example.umc_9th.domain.member.entity; import com.example.umc_9th.domain.member.enums.Gender; +import com.example.umc_9th.global.auth.enums.Role; import com.example.umc_9th.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -42,4 +43,10 @@ public class Member extends BaseEntity { @Column(length = 40, unique = true) private String email; + + @Column(nullable = false) + private String password; + + @Enumerated(EnumType.STRING) + private Role role; } diff --git a/src/main/java/com/example/umc_9th/domain/member/exception/code/MemberErrorCode.java b/src/main/java/com/example/umc_9th/domain/member/exception/code/MemberErrorCode.java index 2e860bd..ce34515 100644 --- a/src/main/java/com/example/umc_9th/domain/member/exception/code/MemberErrorCode.java +++ b/src/main/java/com/example/umc_9th/domain/member/exception/code/MemberErrorCode.java @@ -13,7 +13,7 @@ public enum MemberErrorCode implements BaseErrorCode { NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "해당 사용자를 찾지 못했습니다."), - ; + INVALID(HttpStatus.UNAUTHORIZED,"MEMBER403_1" , "존재하지 않는 사용자입니다"); // 1. 필드명을 httpStatus로 변경 (Lombok @Getter와의 통일성 권장) private final HttpStatus httpStatus; diff --git a/src/main/java/com/example/umc_9th/domain/member/repository/MemberRepository.java b/src/main/java/com/example/umc_9th/domain/member/repository/MemberRepository.java index bafe5cc..6cf1a33 100644 --- a/src/main/java/com/example/umc_9th/domain/member/repository/MemberRepository.java +++ b/src/main/java/com/example/umc_9th/domain/member/repository/MemberRepository.java @@ -7,5 +7,6 @@ public interface MemberRepository extends JpaRepository { + Optional findByEmail(String email); Optional findMyPageDtoById(Long id); } \ No newline at end of file diff --git a/src/main/java/com/example/umc_9th/domain/member/service/command/MemberCommandServiceImpl.java b/src/main/java/com/example/umc_9th/domain/member/service/command/MemberCommandServiceImpl.java index 0b41875..24d0b09 100644 --- a/src/main/java/com/example/umc_9th/domain/member/service/command/MemberCommandServiceImpl.java +++ b/src/main/java/com/example/umc_9th/domain/member/service/command/MemberCommandServiceImpl.java @@ -11,7 +11,9 @@ import com.example.umc_9th.domain.member.repository.FoodRepository; import com.example.umc_9th.domain.member.repository.MemberFoodRepository; import com.example.umc_9th.domain.member.repository.MemberRepository; +import com.example.umc_9th.global.auth.enums.Role; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -26,13 +28,16 @@ public class MemberCommandServiceImpl implements MemberCommandService { private final MemberRepository memberRepository; private final MemberFoodRepository memberFoodRepository; private final FoodRepository foodRepository; + private final PasswordEncoder passwordEncoder; @Override public MemberResDTO.JoinDTO signup( MemberReqDTO.JoinDTO dto ){ - Member member = MemberConverter.toMember(dto); + String salt = passwordEncoder.encode(dto.password()); + + Member member = MemberConverter.toMember(dto, salt, Role.ROLE_USER); memberRepository.save(member); // 선호 음식 존재 여부 확인 diff --git a/src/main/java/com/example/umc_9th/domain/member/service/query/MemberQueryService.java b/src/main/java/com/example/umc_9th/domain/member/service/query/MemberQueryService.java index ec4ab65..e9e9577 100644 --- a/src/main/java/com/example/umc_9th/domain/member/service/query/MemberQueryService.java +++ b/src/main/java/com/example/umc_9th/domain/member/service/query/MemberQueryService.java @@ -1,10 +1,19 @@ package com.example.umc_9th.domain.member.service.query; +import com.example.umc_9th.domain.member.converter.MemberConverter; +import com.example.umc_9th.domain.member.dto.MemberReqDTO; +import com.example.umc_9th.domain.member.dto.MemberResDTO; import com.example.umc_9th.domain.member.dto.MyPageDto; +import com.example.umc_9th.domain.member.entity.Member; import com.example.umc_9th.domain.member.repository.MemberRepository; import com.example.umc_9th.global.apiPayload.code.status.MemberErrorCode; + + import com.example.umc_9th.global.apiPayload.handler.MemberHandler; +import com.example.umc_9th.global.auth.entity.CustomUserDetails; +import com.example.umc_9th.global.auth.jwt.JwtUtil; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,10 +23,34 @@ public class MemberQueryService { private final MemberRepository memberRepository; + private final JwtUtil jwtUtil; // 로그인 기능을 위해 추가 + private final PasswordEncoder encoder; // 로그인 기능을 위해 추가 + // 1. 마이페이지 조회 public MyPageDto getMyPageInfo(Long memberId) { - return memberRepository.findMyPageDtoById(memberId) .orElseThrow(() -> new MemberHandler(MemberErrorCode.MEMBER_NOT_FOUND)); } -} + + // 2. 로그인 (Impl에 있던 로직을 여기로 통합) + // 서비스 계층에서는 @Valid를 쓰지 않습니다. Controller에서 이미 검증된 데이터가 넘어옵니다. + public MemberResDTO.LoginDTO login(MemberReqDTO.LoginDTO dto) { + + // 이메일로 회원 조회 + Member member = memberRepository.findByEmail(dto.email()) + .orElseThrow(() -> new MemberHandler(MemberErrorCode.MEMBER_NOT_FOUND)); + + // 비밀번호 검증 + if (!encoder.matches(dto.password(), member.getPassword())){ + // 비밀번호 틀림 -> 보안상 NOT_FOUND와 동일하게 처리하거나 별도 에러코드 사용 + throw new MemberHandler(MemberErrorCode.MEMBER_NOT_FOUND); + } + + // 토큰 발급 + CustomUserDetails userDetails = new CustomUserDetails(member); + String accessToken = jwtUtil.createAccessToken(userDetails); + + // 응답 DTO 반환 + return MemberConverter.toLoginDTO(member, accessToken); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc_9th/domain/mission/controller/MemberMissionController.java b/src/main/java/com/example/umc_9th/domain/mission/controller/MemberMissionController.java index 7e4588b..6204c67 100644 --- a/src/main/java/com/example/umc_9th/domain/mission/controller/MemberMissionController.java +++ b/src/main/java/com/example/umc_9th/domain/mission/controller/MemberMissionController.java @@ -5,12 +5,17 @@ import com.example.umc_9th.domain.member.dto.MemberMissionResponseDTO; import com.example.umc_9th.domain.mission.converter.MemberMissionConverter; +import com.example.umc_9th.domain.mission.converter.MissionConverter; +import com.example.umc_9th.domain.mission.dto.MissionDTO; import com.example.umc_9th.domain.mission.entity.MemberMission; import com.example.umc_9th.domain.mission.service.MemberMissionCommandService; +import com.example.umc_9th.domain.mission.service.MemberMissionQueryService; import com.example.umc_9th.global.apiPayload.ApiResponse; import com.example.umc_9th.global.apiPayload.code.GeneralSuccessCode; +import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; import org.springframework.web.bind.annotation.*; @RestController @@ -19,6 +24,7 @@ public class MemberMissionController { private final MemberMissionCommandService memberMissionCommandService; + private final MemberMissionQueryService memberMissionQueryService; @PostMapping("/challenge") public ApiResponse challengeMission( @@ -32,4 +38,14 @@ public ApiResponse challengeMission( MemberMissionConverter.toJoinResultDTO(memberMission) ); } + + @GetMapping("/members/my") + @Operation(summary = "내가 진행중인 미션 목록 조회 API", description = "내가 현재 도전 중인 미션 목록을 조회합니다.") + public ApiResponse getMyChallengingMissions( + @RequestParam(name = "memberId") Long memberId, + @RequestParam(name = "page") Integer page + ) { + Page missionPage = memberMissionQueryService.getMyChallengingMissions(memberId, page - 1); + return ApiResponse.onSuccess(GeneralSuccessCode.OK, MissionConverter.toChallengingMissionListDTO(missionPage)); + } } diff --git a/src/main/java/com/example/umc_9th/domain/mission/converter/MissionConverter.java b/src/main/java/com/example/umc_9th/domain/mission/converter/MissionConverter.java index 59347bf..452732c 100644 --- a/src/main/java/com/example/umc_9th/domain/mission/converter/MissionConverter.java +++ b/src/main/java/com/example/umc_9th/domain/mission/converter/MissionConverter.java @@ -3,8 +3,12 @@ import com.example.umc_9th.domain.mission.dto.AvailableMissionDto; import com.example.umc_9th.domain.mission.dto.MissionDTO; import com.example.umc_9th.domain.mission.dto.MissionStatusDto; +import com.example.umc_9th.domain.mission.entity.MemberMission; import org.springframework.data.domain.Page; +import java.util.List; +import java.util.stream.Collectors; + public class MissionConverter { public static MissionDTO.AvailableMissionListDTO toAvailableMissionListDTO(Page page) { @@ -26,4 +30,31 @@ public static MissionDTO.MyMissionListDTO toMyMissionListDTO(Page DTO 변환 + public static MissionDTO.ChallengingMissionResponseDTO toChallengingMissionDTO(MemberMission memberMission) { + return MissionDTO.ChallengingMissionResponseDTO.builder() + .memberMissionId(memberMission.getId()) + .storeName(memberMission.getMission().getStore().getName()) // 연관관계 탐색 + .missionSpec(memberMission.getMission().getMissionSpec()) + .reward(memberMission.getMission().getReward().intValue()) + .deadline(memberMission.getMission().getDeadline()) + .build(); + } + + // Page -> List DTO 변환 + public static MissionDTO.ChallengingMissionListDTO toChallengingMissionListDTO(Page page) { + List dtoList = page.stream() + .map(MissionConverter::toChallengingMissionDTO) + .collect(Collectors.toList()); + + return MissionDTO.ChallengingMissionListDTO.builder() + .missionList(dtoList) + .listSize(dtoList.size()) + .totalPage(page.getTotalPages()) + .totalElements(page.getTotalElements()) + .isFirst(page.isFirst()) + .isLast(page.isLast()) + .build(); + } } \ No newline at end of file diff --git a/src/main/java/com/example/umc_9th/domain/mission/dto/MissionDTO.java b/src/main/java/com/example/umc_9th/domain/mission/dto/MissionDTO.java index ff1b67b..80bf3cb 100644 --- a/src/main/java/com/example/umc_9th/domain/mission/dto/MissionDTO.java +++ b/src/main/java/com/example/umc_9th/domain/mission/dto/MissionDTO.java @@ -1,7 +1,11 @@ package com.example.umc_9th.domain.mission.dto; +import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; import java.util.List; public class MissionDTO { @@ -27,4 +31,29 @@ public static class MyMissionListDTO { private Boolean isFirst; private Boolean isLast; } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class ChallengingMissionResponseDTO { + private Long memberMissionId; + private String storeName; + private String missionSpec; + private Integer reward; + private LocalDate deadline; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class ChallengingMissionListDTO { + private List missionList; + private Integer listSize; + private Integer totalPage; + private Long totalElements; + private Boolean isFirst; + private Boolean isLast; + } } diff --git a/src/main/java/com/example/umc_9th/domain/mission/repository/MemberMissionRepository.java b/src/main/java/com/example/umc_9th/domain/mission/repository/MemberMissionRepository.java index 68aee39..2163f4e 100644 --- a/src/main/java/com/example/umc_9th/domain/mission/repository/MemberMissionRepository.java +++ b/src/main/java/com/example/umc_9th/domain/mission/repository/MemberMissionRepository.java @@ -2,6 +2,7 @@ +import com.example.umc_9th.domain.member.entity.Member; import com.example.umc_9th.domain.mission.dto.MissionStatusDto; import com.example.umc_9th.domain.mission.entity.MemberMission; import com.example.umc_9th.domain.mission.enums.MissionStatus; @@ -15,6 +16,8 @@ public interface MemberMissionRepository extends JpaRepository { + Page findAllByMemberAndStatus(Member member, MissionStatus status, Pageable pageable); + @Query("select new com.example.umc_9th.domain.mission.dto.MissionStatusDto(s.name, m.reward, m.missionSpec, mm.status) " + "from MemberMission mm " + diff --git a/src/main/java/com/example/umc_9th/domain/mission/repository/MissionRepository.java b/src/main/java/com/example/umc_9th/domain/mission/repository/MissionRepository.java index a72c21a..89d16d0 100644 --- a/src/main/java/com/example/umc_9th/domain/mission/repository/MissionRepository.java +++ b/src/main/java/com/example/umc_9th/domain/mission/repository/MissionRepository.java @@ -2,6 +2,7 @@ import com.example.umc_9th.domain.mission.dto.AvailableMissionDto; import com.example.umc_9th.domain.mission.entity.Mission; +import com.example.umc_9th.domain.store.entity.Store; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -10,6 +11,7 @@ public interface MissionRepository extends JpaRepository { + Page findAllByStore(Store store, Pageable pageable); /** * 쿼리 3: 홈 화면 - 현재 지역에서 도전이 가능한 미션 목록 (페이징 포함) */ diff --git a/src/main/java/com/example/umc_9th/domain/mission/service/MemberMissionQueryService.java b/src/main/java/com/example/umc_9th/domain/mission/service/MemberMissionQueryService.java new file mode 100644 index 0000000..9c10b74 --- /dev/null +++ b/src/main/java/com/example/umc_9th/domain/mission/service/MemberMissionQueryService.java @@ -0,0 +1,33 @@ +package com.example.umc_9th.domain.mission.service; + +import com.example.umc_9th.domain.member.entity.Member; +import com.example.umc_9th.domain.member.repository.MemberRepository; +import com.example.umc_9th.domain.mission.entity.MemberMission; +import com.example.umc_9th.domain.mission.enums.MissionStatus; +import com.example.umc_9th.domain.mission.repository.MemberMissionRepository; +import com.example.umc_9th.global.apiPayload.code.status.MemberErrorCode; +import com.example.umc_9th.global.apiPayload.handler.MemberHandler; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MemberMissionQueryService { + + private final MemberMissionRepository memberMissionRepository; + private final MemberRepository memberRepository; + + public Page getMyChallengingMissions(Long memberId, Integer page) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberHandler(MemberErrorCode.MEMBER_NOT_FOUND)); + + PageRequest pageRequest = PageRequest.of(page, 10); + + // 진행 중인(CHALLENGING) 미션만 조회 + return memberMissionRepository.findAllByMemberAndStatus(member, MissionStatus.CHALLENGING, pageRequest); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc_9th/domain/review/controller/ReviewController.java b/src/main/java/com/example/umc_9th/domain/review/controller/ReviewController.java index 2bcda6a..bcb4d2e 100644 --- a/src/main/java/com/example/umc_9th/domain/review/controller/ReviewController.java +++ b/src/main/java/com/example/umc_9th/domain/review/controller/ReviewController.java @@ -7,6 +7,9 @@ import com.example.umc_9th.domain.review.service.ReviewService; import com.example.umc_9th.global.apiPayload.ApiResponse; import com.example.umc_9th.global.apiPayload.code.GeneralSuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -33,8 +36,7 @@ public ApiResponse getMyReviews( return ApiResponse.onSuccess( GeneralSuccessCode.OK, - ReviewConverter.toMyReviewListDTO(reviewPage) - ); + ReviewConverter.toMyReviewListDTOFromDTO(reviewPage) ); } // 2. 리뷰 작성 API @@ -53,4 +55,18 @@ public ApiResponse createReview( ReviewConverter.toCreateReviewResponseDTO(review) ); } + + @GetMapping("/members/my-reviews") + @Operation(summary = "내가 작성한 리뷰 목록 조회 API", description = "내가 작성한 리뷰들의 목록을 조회합니다. 페이징을 포함합니다.") + @Parameters({ + @Parameter(name = "memberId", description = "사용자 ID (토큰 대체 예정)"), + @Parameter(name = "page", description = "페이지 번호 (1부터 시작)") + }) + public ApiResponse getMyReviewList( + @RequestParam(name = "memberId") Long memberId, + @RequestParam(name = "page") Integer page + ) { + Page reviewPage = reviewService.getMyReviewList(memberId, page - 1); + return ApiResponse.onSuccess(GeneralSuccessCode.OK, ReviewConverter.toMyReviewListDTO(reviewPage)); // 기존 Converter 재사용 + } } \ No newline at end of file diff --git a/src/main/java/com/example/umc_9th/domain/review/converter/ReviewConverter.java b/src/main/java/com/example/umc_9th/domain/review/converter/ReviewConverter.java index a03c253..e0b980d 100644 --- a/src/main/java/com/example/umc_9th/domain/review/converter/ReviewConverter.java +++ b/src/main/java/com/example/umc_9th/domain/review/converter/ReviewConverter.java @@ -9,6 +9,8 @@ import org.springframework.data.domain.Page; import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; public class ReviewConverter { @@ -37,22 +39,43 @@ public static Review toReview(ReviewRequestDTO.JoinDTO request, Member member, S .build(); } - // 3. 내가 쓴 리뷰 조회 (GET) : Page -> MyReviewListDTO - public static ReviewDTO.MyReviewListDTO toMyReviewListDTO(Page page) { + + + public static ReviewDTO.CreateReviewResponseDTO toCreateReviewResultDTO(Review review){ + return ReviewDTO.CreateReviewResponseDTO.builder() + .reviewId(review.getId()) + .createdAt(LocalDateTime.now()) + .build(); + } + + public static ReviewDTO.MyReviewListDTO toMyReviewListDTO(Page page) { + List myReviewDtos = page.stream() + .map(review -> new MyReviewDto( + review.getStore().getName(), + review.getScore(), + review.getBody() + )) + .collect(Collectors.toList()); + return ReviewDTO.MyReviewListDTO.builder() - .reviewList(page.getContent()) - .listSize(page.getContent().size()) + .isLast(page.isLast()) + .isFirst(page.isFirst()) .totalPage(page.getTotalPages()) .totalElements(page.getTotalElements()) - .isFirst(page.isFirst()) - .isLast(page.isLast()) + .listSize(myReviewDtos.size()) + .reviewList(myReviewDtos) .build(); } - public static ReviewDTO.CreateReviewResponseDTO toCreateReviewResultDTO(Review review){ - return ReviewDTO.CreateReviewResponseDTO.builder() - .reviewId(review.getId()) - .createdAt(LocalDateTime.now()) + public static ReviewDTO.MyReviewListDTO toMyReviewListDTOFromDTO(Page page) { + return ReviewDTO.MyReviewListDTO.builder() + .isLast(page.isLast()) + .isFirst(page.isFirst()) + .totalPage(page.getTotalPages()) + .totalElements(page.getTotalElements()) + .listSize(page.getContent().size()) + .reviewList(page.getContent()) // 이미 변환되어 있으므로 그대로 넣음 .build(); } + } diff --git a/src/main/java/com/example/umc_9th/domain/review/dto/ReviewResDTO.java b/src/main/java/com/example/umc_9th/domain/review/dto/ReviewResDTO.java new file mode 100644 index 0000000..bc922da --- /dev/null +++ b/src/main/java/com/example/umc_9th/domain/review/dto/ReviewResDTO.java @@ -0,0 +1,27 @@ +package com.example.umc_9th.domain.review.dto; + +import lombok.Builder; + +import java.time.LocalDate; +import java.util.List; + +public class ReviewResDTO { + + @Builder + public record ReviewPreViewListDTO( + List reviewList, + Integer listSize, + Integer totalPage, + Long totalElements, + Boolean isFirst, + Boolean isLast + ){} + + @Builder + public record ReviewPreViewDTO( + String ownerNickname, + Float score, + String body, + LocalDate createdAt + ){} +} diff --git a/src/main/java/com/example/umc_9th/domain/review/repository/ReviewRepository.java b/src/main/java/com/example/umc_9th/domain/review/repository/ReviewRepository.java index f3cdbf0..8f8187e 100644 --- a/src/main/java/com/example/umc_9th/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/example/umc_9th/domain/review/repository/ReviewRepository.java @@ -1,8 +1,13 @@ package com.example.umc_9th.domain.review.repository; +import com.example.umc_9th.domain.member.entity.Member; import com.example.umc_9th.domain.review.entity.Review; +import com.example.umc_9th.domain.store.entity.Store; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; public interface ReviewRepository extends JpaRepository, ReviewRepositoryCustom { - + Page findAllByStore(Store store, Pageable pageable); + Page findAllByMember(Member member, Pageable pageable); } diff --git a/src/main/java/com/example/umc_9th/domain/review/service/ReviewService.java b/src/main/java/com/example/umc_9th/domain/review/service/ReviewService.java index b131853..e8b07de 100644 --- a/src/main/java/com/example/umc_9th/domain/review/service/ReviewService.java +++ b/src/main/java/com/example/umc_9th/domain/review/service/ReviewService.java @@ -16,6 +16,7 @@ import com.example.umc_9th.global.apiPayload.handler.StoreHandler; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -68,4 +69,13 @@ public Review createReview(Long memberId, Long storeId, ReviewRequestDTO.JoinDTO return reviewRepository.save(review); } + + @Transactional(readOnly = true) + public Page getMyReviewList(Long memberId, Integer page) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new MemberHandler(MemberErrorCode.MEMBER_NOT_FOUND)); + + PageRequest pageRequest = PageRequest.of(page, 10); // 페이지당 10개 + return reviewRepository.findAllByMember(member, pageRequest); + } } \ No newline at end of file diff --git a/src/main/java/com/example/umc_9th/domain/store/controller/StoreController.java b/src/main/java/com/example/umc_9th/domain/store/controller/StoreController.java new file mode 100644 index 0000000..b738506 --- /dev/null +++ b/src/main/java/com/example/umc_9th/domain/store/controller/StoreController.java @@ -0,0 +1,55 @@ +package com.example.umc_9th.domain.store.controller; + +import com.example.umc_9th.domain.mission.entity.Mission; +import com.example.umc_9th.domain.review.entity.Review; +import com.example.umc_9th.domain.store.converter.StoreConverter; +import com.example.umc_9th.domain.store.dto.StoreResponseDTO; +import com.example.umc_9th.domain.store.service.StoreQueryService; +import com.example.umc_9th.global.apiPayload.ApiResponse; +import com.example.umc_9th.global.apiPayload.code.GeneralSuccessCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.Parameters; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/stores") +public class StoreController { + + private final StoreQueryService storeQueryService; + + @Operation(summary = "특정 가게의 리뷰 목록 조회 API", description = "특정 가게의 리뷰들의 목록을 조회하는 API이며, 페이징을 포함합니다. query String으로 page 번호를 주세요.") + @GetMapping("/{storeId}/reviews") + @Parameters({ + @Parameter(name = "storeId", description = "가게의 아이디, path variable 입니다!"), + @Parameter(name = "page", description = "페이지 번호, 1번이 1 페이지 입니다.") + }) + public ApiResponse getReviewList( + @PathVariable(name = "storeId") Long storeId, + @RequestParam(name = "page") Integer page + ){ + // 사용자가 보낸 page는 1부터 시작한다고 가정 -> Service에 넘길 땐 0부터 시작하게 -1 + Page reviewPage = storeQueryService.getReviewList(storeId, page - 1); + + return ApiResponse.onSuccess(GeneralSuccessCode.OK, StoreConverter.toReviewPreViewListDTO(reviewPage)); + } + + @Operation(summary = "특정 가게의 미션 목록 조회 API", description = "특정 가게의 미션들의 목록을 조회하는 API이며, 페이징을 포함합니다. query String으로 page 번호를 주세요.") + @GetMapping("/{storeId}/missions") + @Parameters({ + @Parameter(name = "storeId", description = "가게의 아이디, path variable 입니다!"), + @Parameter(name = "page", description = "페이지 번호, 1번이 1 페이지 입니다.") + }) + public ApiResponse getMissionList( + @PathVariable(name = "storeId") Long storeId, + @RequestParam(name = "page") Integer page + ){ + Page missionPage = storeQueryService.getMissionList(storeId, page - 1); + + return ApiResponse.onSuccess(GeneralSuccessCode.OK, StoreConverter.toMissionPreViewListDTO(missionPage)); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc_9th/domain/store/converter/StoreConverter.java b/src/main/java/com/example/umc_9th/domain/store/converter/StoreConverter.java new file mode 100644 index 0000000..5dfc861 --- /dev/null +++ b/src/main/java/com/example/umc_9th/domain/store/converter/StoreConverter.java @@ -0,0 +1,60 @@ +package com.example.umc_9th.domain.store.converter; + +import com.example.umc_9th.domain.mission.entity.Mission; +import com.example.umc_9th.domain.review.entity.Review; +import com.example.umc_9th.domain.store.dto.StoreResponseDTO; +import org.springframework.data.domain.Page; + +import java.util.List; +import java.util.stream.Collectors; + +public class StoreConverter { + + // --- 리뷰 변환 --- + public static StoreResponseDTO.ReviewPreViewDTO toReviewPreViewDTO(Review review){ + return StoreResponseDTO.ReviewPreViewDTO.builder() + .ownerNickname(review.getMember().getName()) + .score(review.getScore()) + .body(review.getBody()) + .createdAt(review.getCreatedAt()) + .build(); + } + + public static StoreResponseDTO.ReviewPreViewListDTO toReviewPreViewListDTO(Page reviewList){ + List reviewPreViewDTOList = reviewList.stream() + .map(StoreConverter::toReviewPreViewDTO).collect(Collectors.toList()); + + return StoreResponseDTO.ReviewPreViewListDTO.builder() + .isLast(reviewList.isLast()) + .isFirst(reviewList.isFirst()) + .totalPage(reviewList.getTotalPages()) + .totalElements(reviewList.getTotalElements()) + .listSize(reviewPreViewDTOList.size()) + .reviewList(reviewPreViewDTOList) + .build(); + } + + // --- 미션 변환 --- + public static StoreResponseDTO.MissionPreViewDTO toMissionPreViewDTO(Mission mission){ + return StoreResponseDTO.MissionPreViewDTO.builder() + .missionId(mission.getId()) + .reward(mission.getReward().intValue()) // Long -> Integer 형변환 필요시 + .deadline(mission.getDeadline()) + .missionSpec(mission.getMissionSpec()) + .build(); + } + + public static StoreResponseDTO.MissionPreViewListDTO toMissionPreViewListDTO(Page missionList){ + List missionPreViewDTOList = missionList.stream() + .map(StoreConverter::toMissionPreViewDTO).collect(Collectors.toList()); + + return StoreResponseDTO.MissionPreViewListDTO.builder() + .isLast(missionList.isLast()) + .isFirst(missionList.isFirst()) + .totalPage(missionList.getTotalPages()) + .totalElements(missionList.getTotalElements()) + .listSize(missionPreViewDTOList.size()) + .missionList(missionPreViewDTOList) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc_9th/domain/store/dto/StoreResponseDTO.java b/src/main/java/com/example/umc_9th/domain/store/dto/StoreResponseDTO.java new file mode 100644 index 0000000..dbc5075 --- /dev/null +++ b/src/main/java/com/example/umc_9th/domain/store/dto/StoreResponseDTO.java @@ -0,0 +1,65 @@ +package com.example.umc_9th.domain.store.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +public class StoreResponseDTO { + + // --- 리뷰 관련 DTO --- + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class ReviewPreViewListDTO { + private List reviewList; + private Integer listSize; + private Integer totalPage; + private Long totalElements; + private Boolean isFirst; + private Boolean isLast; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class ReviewPreViewDTO { + private String ownerNickname; + private Float score; + private String body; + private LocalDateTime createdAt; + } + + // --- 미션 관련 DTO --- + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class MissionPreViewListDTO { + private List missionList; + private Integer listSize; + private Integer totalPage; + private Long totalElements; + private Boolean isFirst; + private Boolean isLast; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class MissionPreViewDTO { + private Long missionId; + private Integer reward; + private LocalDate deadline; + private String missionSpec; + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc_9th/domain/store/service/StoreQueryService.java b/src/main/java/com/example/umc_9th/domain/store/service/StoreQueryService.java new file mode 100644 index 0000000..66a051b --- /dev/null +++ b/src/main/java/com/example/umc_9th/domain/store/service/StoreQueryService.java @@ -0,0 +1,47 @@ +package com.example.umc_9th.domain.store.service; + +import com.example.umc_9th.domain.mission.entity.Mission; +import com.example.umc_9th.domain.mission.repository.MissionRepository; +import com.example.umc_9th.domain.review.entity.Review; +import com.example.umc_9th.domain.review.repository.ReviewRepository; +import com.example.umc_9th.domain.store.entity.Store; +import com.example.umc_9th.domain.store.repository.StoreRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class StoreQueryService { + + private final StoreRepository storeRepository; + private final ReviewRepository reviewRepository; + private final MissionRepository missionRepository; + + public Store findStore(Long id) { + return storeRepository.findById(id).orElseThrow(() -> new RuntimeException("가게를 찾을 수 없습니다.")); + } + + // 리뷰 목록 조회 + public Page getReviewList(Long storeId, Integer page) { + Store store = findStore(storeId); + + // page는 0부터 시작하므로, Controller에서 받은 page-1을 할 수도 있고 여기서 할 수도 있습니다. + // 여기서는 Controller에서 1-based index로 받고, 여기서 0-based로 변환한다고 가정 (page - 1) + PageRequest pageRequest = PageRequest.of(page, 10); // 페이지 사이즈 10 고정 + + return reviewRepository.findAllByStore(store, pageRequest); + } + + // 미션 목록 조회 + public Page getMissionList(Long storeId, Integer page) { + Store store = findStore(storeId); + + PageRequest pageRequest = PageRequest.of(page, 10); + + return missionRepository.findAllByStore(store, pageRequest); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc_9th/global/apiPayload/code/status/MemberErrorCode.java b/src/main/java/com/example/umc_9th/global/apiPayload/code/status/MemberErrorCode.java index fe317a0..f587a70 100644 --- a/src/main/java/com/example/umc_9th/global/apiPayload/code/status/MemberErrorCode.java +++ b/src/main/java/com/example/umc_9th/global/apiPayload/code/status/MemberErrorCode.java @@ -11,7 +11,8 @@ public enum MemberErrorCode implements BaseErrorCode { // 에러 코드 정의 - MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "사용자를 찾을 수 없습니다."); + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "MEMBER404_1", "사용자를 찾을 수 없습니다."), + INVALID(HttpStatus.UNAUTHORIZED , "MEMBER404_1", "사용자를 찾을 수 없습니다."); // 1. 필드명 통일 (status -> httpStatus) // Lombok @Getter가 getHttpStatus()를 만들기 위함입니다. @@ -38,4 +39,5 @@ public ErrorReasonDTO getReasonHttpStatus() { .httpStatus(httpStatus) .build(); } + } diff --git a/src/main/java/com/example/umc_9th/global/auth/entity/CustomUserDetails.java b/src/main/java/com/example/umc_9th/global/auth/entity/CustomUserDetails.java new file mode 100644 index 0000000..bea3f09 --- /dev/null +++ b/src/main/java/com/example/umc_9th/global/auth/entity/CustomUserDetails.java @@ -0,0 +1,30 @@ +package com.example.umc_9th.global.auth.entity; + +import com.example.umc_9th.domain.member.entity.Member; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; + +import java.util.Collection; +import java.util.List; + +@RequiredArgsConstructor +public class CustomUserDetails implements UserDetails { + + private final Member member; + + @Override + public Collection getAuthorities() { + return List.of(() -> member.getRole().toString()); + } + + @Override + public String getPassword() { + return member.getPassword(); + } + + @Override + public String getUsername() { + return member.getEmail(); + } +} diff --git a/src/main/java/com/example/umc_9th/global/auth/enums/Role.java b/src/main/java/com/example/umc_9th/global/auth/enums/Role.java new file mode 100644 index 0000000..95e1060 --- /dev/null +++ b/src/main/java/com/example/umc_9th/global/auth/enums/Role.java @@ -0,0 +1,5 @@ +package com.example.umc_9th.global.auth.enums; + +public enum Role { + ROLE_ADMIN, ROLE_USER +} diff --git a/src/main/java/com/example/umc_9th/global/auth/filter/AuthenticationEntryPointImpl.java b/src/main/java/com/example/umc_9th/global/auth/filter/AuthenticationEntryPointImpl.java new file mode 100644 index 0000000..675d9b6 --- /dev/null +++ b/src/main/java/com/example/umc_9th/global/auth/filter/AuthenticationEntryPointImpl.java @@ -0,0 +1,34 @@ +package com.example.umc_9th.global.auth.filter; + +import com.example.umc_9th.global.apiPayload.ApiResponse; +import com.example.umc_9th.global.apiPayload.code.GeneralErrorCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; + +import java.io.IOException; + +public class AuthenticationEntryPointImpl implements AuthenticationEntryPoint { + private final ObjectMapper objectMapper = new ObjectMapper(); + + + @Override + public void commence( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException + ) throws IOException { + response.setContentType("application/json;charset=UTF-8"); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + + ApiResponse errorResponse = ApiResponse.onFailure( + "UNAUTHORIZED", + "sdf", + null + ); + + objectMapper.writeValue(response.getOutputStream(), errorResponse); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc_9th/global/auth/jwt/JwtAuthFilter.java b/src/main/java/com/example/umc_9th/global/auth/jwt/JwtAuthFilter.java new file mode 100644 index 0000000..f0e6d96 --- /dev/null +++ b/src/main/java/com/example/umc_9th/global/auth/jwt/JwtAuthFilter.java @@ -0,0 +1,62 @@ +package com.example.umc_9th.global.auth.jwt; + +import com.example.umc_9th.global.apiPayload.ApiResponse; +import com.example.umc_9th.global.apiPayload.code.GeneralErrorCode; +import com.example.umc_9th.global.auth.service.CustomUserDetailsService; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + + +@RequiredArgsConstructor +public class JwtAuthFilter extends OncePerRequestFilter { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain + ) throws ServletException, IOException { + + // 토큰 가져오기 + String token = request.getHeader("Authorization"); + // token이 없거나 Bearer가 아니면 넘기기 + if (token == null || !token.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + return; + } + // Bearer이면 추출 + token = token.replace("Bearer ", ""); + // AccessToken 검증하기: 올바른 토큰이면 + if (jwtUtil.isValid(token)) { + // 토큰에서 이메일 추출 + String email = jwtUtil.getEmail(token); + // 인증 객체 생성: 이메일로 찾아온 뒤, 인증 객체 생성 + UserDetails user = customUserDetailsService.loadUserByUsername(email); + Authentication auth = new UsernamePasswordAuthenticationToken( + user, + null, + user.getAuthorities() + ); + // 인증 완료 후 SecurityContextHolder에 넣기 + SecurityContextHolder.getContext().setAuthentication(auth); + } + filterChain.doFilter(request, response); + + + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc_9th/global/auth/jwt/JwtUtil.java b/src/main/java/com/example/umc_9th/global/auth/jwt/JwtUtil.java new file mode 100644 index 0000000..f82d78b --- /dev/null +++ b/src/main/java/com/example/umc_9th/global/auth/jwt/JwtUtil.java @@ -0,0 +1,93 @@ +package com.example.umc_9th.global.auth.jwt; + +import com.example.umc_9th.global.auth.entity.CustomUserDetails; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.time.Instant; +import java.util.Date; +import java.util.stream.Collectors; + +@Component +public class JwtUtil { + + private final SecretKey secretKey; + private final Duration accessExpiration; + + public JwtUtil( + @Value("${jwt.token.secretKey}") String secret, + @Value("${jwt.token.expiration.access}") Long accessExpiration + ) { + this.secretKey = Keys.hmacShaKeyFor(secret.getBytes(StandardCharsets.UTF_8)); + this.accessExpiration = Duration.ofMillis(accessExpiration); + } + + // AccessToken 생성 + public String createAccessToken(CustomUserDetails user) { + return createToken(user, accessExpiration); + } + + /** 토큰에서 이메일 가져오기 + * + * @param token 유저 정보를 추출할 토큰 + * @return 유저 이메일을 토큰에서 추출합니다 + */ + public String getEmail(String token) { + try { + return getClaims(token).getPayload().getSubject(); // Parsing해서 Subject 가져오기 + } catch (JwtException e) { + return null; + } + } + + /** 토큰 유효성 확인 + * + * @param token 유효한지 확인할 토큰 + * @return True, False 반환합니다 + */ + public boolean isValid(String token) { + try { + getClaims(token); + return true; + } catch (JwtException e) { + return false; + } + } + + // 토큰 생성 + private String createToken(CustomUserDetails user, Duration expiration) { + Instant now = Instant.now(); + + // 인가 정보 + String authorities = user.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.joining(",")); + + return Jwts.builder() + .subject(user.getUsername()) // User 이메일을 Subject로 + .claim("role", authorities) + .claim("email", user.getUsername()) + .issuedAt(Date.from(now)) // 언제 발급한지 + .expiration(Date.from(now.plus(expiration))) // 언제까지 유효한지 + .signWith(secretKey) // sign할 Key + .compact(); + } + + // 토큰 정보 가져오기 + private Jws getClaims(String token) throws JwtException { + return Jwts.parser() + .verifyWith(secretKey) + .clockSkewSeconds(60) + .build() + .parseSignedClaims(token); + } +} \ No newline at end of file diff --git a/src/main/java/com/example/umc_9th/global/auth/service/CustomUserDetailsService.java b/src/main/java/com/example/umc_9th/global/auth/service/CustomUserDetailsService.java new file mode 100644 index 0000000..ec495c2 --- /dev/null +++ b/src/main/java/com/example/umc_9th/global/auth/service/CustomUserDetailsService.java @@ -0,0 +1,30 @@ +package com.example.umc_9th.global.auth.service; + +import com.example.umc_9th.domain.member.entity.Member; +import com.example.umc_9th.domain.member.exception.MemberException; +import com.example.umc_9th.domain.member.exception.code.MemberErrorCode; +import com.example.umc_9th.domain.member.repository.MemberRepository; +import com.example.umc_9th.global.auth.entity.CustomUserDetails; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class CustomUserDetailsService implements UserDetailsService { + + private final MemberRepository memberRepository; + + @Override + public UserDetails loadUserByUsername( + String username + ) throws UsernameNotFoundException { + // 검증할 Member 조회 + Member member = memberRepository.findByEmail(username) + .orElseThrow(() -> new MemberException(MemberErrorCode.NOT_FOUND)); + // CustomUserDetails 반환 + return new CustomUserDetails(member); + } +} diff --git a/src/main/java/com/example/umc_9th/global/config/SecurityConfig.java b/src/main/java/com/example/umc_9th/global/config/SecurityConfig.java new file mode 100644 index 0000000..ec96661 --- /dev/null +++ b/src/main/java/com/example/umc_9th/global/config/SecurityConfig.java @@ -0,0 +1,76 @@ +package com.example.umc_9th.global.config; + +import com.example.umc_9th.global.auth.filter.AuthenticationEntryPointImpl; +import com.example.umc_9th.global.auth.jwt.JwtAuthFilter; +import com.example.umc_9th.global.auth.jwt.JwtUtil; +import com.example.umc_9th.global.auth.service.CustomUserDetailsService; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@EnableWebSecurity +@Configuration +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtUtil jwtUtil; + private final CustomUserDetailsService customUserDetailsService; + + private final String[] allowUris = { + "/login", + "/sign-up", + "/swagger-ui/**", + "/swagger-resources/**", + "/v3/api-docs/**", + }; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .authorizeHttpRequests(requests -> requests + .requestMatchers(allowUris).permitAll() + .requestMatchers("/admin/**").hasRole("ADMIN") + .anyRequest().authenticated() + ) + // 폼로그인 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + // JwtAuthFilter를 UsernamePasswordAuthenticationFilter + .addFilterBefore(jwtAuthFilter(), UsernamePasswordAuthenticationFilter.class) + .csrf(AbstractHttpConfigurer::disable) + .logout(logout -> logout + .logoutUrl("/logout") + .logoutSuccessUrl("/login?logout") + .permitAll() + ) + .exceptionHandling(exception -> exception.authenticationEntryPoint(authenticationEntryPoint())) + + ; + + return http.build(); + } + + @Bean + public JwtAuthFilter jwtAuthFilter() { + return new JwtAuthFilter(jwtUtil, customUserDetailsService); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + + @Bean + public AuthenticationEntryPoint authenticationEntryPoint() { + return new AuthenticationEntryPointImpl(); + } + +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c823362..9a2c812 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -16,4 +16,9 @@ spring: ddl-auto: update # ?????? ?? ? ?????? ???? ??? ?? properties: hibernate: - format_sql: true # ???? SQL ??? ?? ?? ??? \ No newline at end of file + format_sql: true # ???? SQL ??? ?? ?? ??? +jwt: + token: + secretKey: ZGh3YWlkc2F2ZXdhZXZ3b2EgMTM5ZXUgMDMxdWMyIHEyMiBAIDAgKTJFVio= + expiration: + access: 14400000 \ No newline at end of file