diff --git a/.github/workflows/dev_deploy.yml b/.github/workflows/dev_deploy.yml index f637f19..9069116 100644 --- a/.github/workflows/dev_deploy.yml +++ b/.github/workflows/dev_deploy.yml @@ -88,4 +88,4 @@ jobs: utcOffset: "+09:00" - name: Print Current Time run: echo "Current Time=${{steps.current-time.outputs.formattedTime}}" - shell: bash + shell: bash \ No newline at end of file diff --git a/build.gradle b/build.gradle index 1666fe7..4731778 100644 --- a/build.gradle +++ b/build.gradle @@ -43,8 +43,9 @@ dependencies { // 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' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.3' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.3' + implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.1' // CORS 설정 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' @@ -52,7 +53,6 @@ dependencies { // S3 설정 implementation 'org.springframework.cloud:spring-cloud-starter-aws:2.2.6.RELEASE' implementation 'com.amazonaws:aws-java-sdk-s3:1.12.649' - } tasks.named('test') { diff --git a/src/main/java/EatPic/spring/domain/notification/service/NotificationService.java b/src/main/java/EatPic/spring/domain/notification/service/NotificationService.java index f0b4c03..3c68af0 100644 --- a/src/main/java/EatPic/spring/domain/notification/service/NotificationService.java +++ b/src/main/java/EatPic/spring/domain/notification/service/NotificationService.java @@ -1,6 +1,5 @@ package EatPic.spring.domain.notification.service; - import EatPic.spring.domain.card.entity.Card; import EatPic.spring.domain.card.repository.CardRepository; import EatPic.spring.domain.notification.converter.NotificationConverter; @@ -9,9 +8,9 @@ import EatPic.spring.domain.notification.entity.NotificationType; import EatPic.spring.domain.notification.repository.NotificationRepository; import EatPic.spring.domain.user.entity.User; -import EatPic.spring.domain.user.exception.UserErrorCode; import EatPic.spring.domain.user.repository.UserFollowRepository; import EatPic.spring.domain.user.repository.UserRepository; +import EatPic.spring.global.common.code.status.ErrorStatus; import EatPic.spring.global.common.exception.handler.ExceptionHandler; import java.time.LocalDateTime; import java.util.List; @@ -65,7 +64,7 @@ private RecentNotificationResponse convertToDto(Notification notification, User public void checkNotifications(Long userId) { User user = userRepository.findById(userId) - .orElseThrow(() -> new ExceptionHandler(UserErrorCode.USER_NOT_FOUND)); + .orElseThrow(() -> new ExceptionHandler(ErrorStatus.USER_NOT_FOUND)); user.updateLastNotificationCheckAt(LocalDateTime.now()); userRepository.save(user); @@ -73,7 +72,7 @@ public void checkNotifications(Long userId) { public boolean isUnreadNotification(Long userId) { User user = userRepository.findById(userId) - .orElseThrow(() -> new ExceptionHandler(UserErrorCode.USER_NOT_FOUND)); + .orElseThrow(() -> new ExceptionHandler(ErrorStatus.USER_NOT_FOUND)); LocalDateTime lastChecked = user.getLastNotificationCheckAt(); Notification latest = notificationRepository.findTopByReceiverOrderByCreatedAtDesc(user); diff --git a/src/main/java/EatPic/spring/domain/user/controller/RoleTestController.java b/src/main/java/EatPic/spring/domain/user/controller/RoleTestController.java new file mode 100644 index 0000000..54d6fc3 --- /dev/null +++ b/src/main/java/EatPic/spring/domain/user/controller/RoleTestController.java @@ -0,0 +1,54 @@ +//// 계정 권환 확인용 Controller +// +//package EatPic.spring.domain.user.controller; +// +//import io.swagger.v3.oas.annotations.Operation; +//import io.swagger.v3.oas.annotations.tags.Tag; +//import org.springframework.http.ResponseEntity; +//import org.springframework.security.access.prepost.PreAuthorize; +//import org.springframework.security.core.Authentication; +//import org.springframework.web.bind.annotation.GetMapping; +//import org.springframework.web.bind.annotation.RequestMapping; +//import org.springframework.web.bind.annotation.RestController; +// +//import java.util.Map; +//import java.util.stream.Collectors; +// +//@Tag(name = "Role Test") +//@RestController +//@RequestMapping("/api") +//public class RoleTestController { +// +// @Operation(summary = "ADMIN 권한 확인용") +// @PreAuthorize("hasRole('ADMIN')") // ADMIN만 +// @GetMapping("/admin") +// public ResponseEntity admin(Authentication auth) { +// return ResponseEntity.ok(Map.of( +// "who", auth.getName(), +// "authorities", auth.getAuthorities().stream().map(Object::toString).collect(Collectors.toList()), +// "message", "admin ok" +// )); +// } +// +// @Operation(summary = "USER 권한 확인용") +// @PreAuthorize("hasAnyRole('USER','ADMIN')") // USER 또는 ADMIN +// @GetMapping("/user") +// public ResponseEntity user(Authentication auth) { +// return ResponseEntity.ok(Map.of( +// "who", auth.getName(), +// "authorities", auth.getAuthorities().stream().map(Object::toString).collect(Collectors.toList()), +// "message", "user ok" +// )); +// } +// +// @Operation(summary = "현재 토큰 정보 확인") +// @GetMapping("/me") +// public ResponseEntity me(Authentication auth) { +// // 인증 안 되었으면 null일 수 있음 +// if (auth == null) return ResponseEntity.status(401).body(Map.of("message", "unauthorized")); +// return ResponseEntity.ok(Map.of( +// "who", auth.getName(), +// "authorities", auth.getAuthorities().stream().map(Object::toString).collect(Collectors.toList()) +// )); +// } +//} diff --git a/src/main/java/EatPic/spring/domain/user/controller/UserController.java b/src/main/java/EatPic/spring/domain/user/controller/UserController.java index 9dbd3c5..38a6d06 100644 --- a/src/main/java/EatPic/spring/domain/user/controller/UserController.java +++ b/src/main/java/EatPic/spring/domain/user/controller/UserController.java @@ -1,11 +1,17 @@ package EatPic.spring.domain.user.controller; -import EatPic.spring.domain.user.entity.User; +import EatPic.spring.domain.user.dto.*; +import EatPic.spring.domain.user.dto.request.LoginRequestDTO; import EatPic.spring.domain.user.dto.request.SignupRequestDTO; +import EatPic.spring.domain.user.dto.response.LoginResponseDTO; import EatPic.spring.domain.user.dto.response.SignupResponseDTO; +import EatPic.spring.domain.user.entity.User; import EatPic.spring.domain.user.service.UserBadgeService; import EatPic.spring.domain.user.service.UserService; +import EatPic.spring.global.common.ApiResponse; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -14,32 +20,29 @@ @RestController @RequestMapping("/api/auth") @RequiredArgsConstructor -public class UserController { +public class UserController{ private final UserService userService; - private final UserBadgeService userBadgeService; // 회원 가입 요청 @PostMapping("/signup") @Operation(summary = "이메일 회원가입 요청") public ResponseEntity signup(@Valid @RequestBody SignupRequestDTO request) { + return ResponseEntity.ok(userService.signup(request)); + } - User savedUser = userService.signup(request); - - userBadgeService.initializeUserBadges(savedUser); - - SignupResponseDTO response = SignupResponseDTO.builder() - .role(savedUser.getRole()) - .userId(savedUser.getId()) - .email(savedUser.getEmail()) - .nameId(savedUser.getNameId()) - .nickname(savedUser.getNickname()) - .marketingAgreed(savedUser.getMarketingAgreed()) - .notificationAgreed(savedUser.getNotificationAgreed()) - .message("회원가입이 완료되었습니다.") - .build(); - - return ResponseEntity.ok(response); + @PostMapping("/login/email") + @Operation(summary = "이메일 로그인 요청") + public ApiResponse login(@RequestBody @Valid LoginRequestDTO request) { + return ApiResponse.onSuccess(userService.loginUser(request)); } + @GetMapping("/users") + @Operation(summary = "유저 내 정보 조회 - 인증 필요", + security = { @SecurityRequirement(name = "JWT TOKEN") } + ) + public ApiResponse getMyInfo(HttpServletRequest request) { + return ApiResponse.onSuccess(userService.getUserInfo(request)); + } } + diff --git a/src/main/java/EatPic/spring/domain/user/converter/UserConverter.java b/src/main/java/EatPic/spring/domain/user/converter/UserConverter.java index 6444664..37da79f 100644 --- a/src/main/java/EatPic/spring/domain/user/converter/UserConverter.java +++ b/src/main/java/EatPic/spring/domain/user/converter/UserConverter.java @@ -1,10 +1,10 @@ package EatPic.spring.domain.user.converter; import EatPic.spring.domain.card.dto.response.SearchResponseDTO; -import EatPic.spring.domain.card.entity.Card; import EatPic.spring.domain.reaction.dto.ReactionResponseDTO; import EatPic.spring.domain.reaction.entity.ReactionType; -import EatPic.spring.domain.user.dto.request.SignupRequestDTO; +import EatPic.spring.domain.user.dto.UserInfoDTO; +import EatPic.spring.domain.user.dto.response.LoginResponseDTO; import EatPic.spring.domain.user.dto.response.UserResponseDTO; import EatPic.spring.domain.user.entity.User; import EatPic.spring.domain.user.mapping.UserBlock; @@ -13,6 +13,24 @@ public class UserConverter { + public static LoginResponseDTO toLoginResultDTO(User user, String accessToken, String refreshToken) { + return LoginResponseDTO.builder() + .role(user.getRole()) + .userId(user.getId()) + .email(user.getEmail()) + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + public static UserInfoDTO toUserInfoDTO(User user){ + return UserInfoDTO.builder() + .email(user.getEmail()) + .nameId(user.getNameId()) + .nickName(user.getNickname()) + .build(); + } + public static UserResponseDTO.UserIconListResponseDto toUserIconListResponseDto(Page followingPage){ return UserResponseDTO.UserIconListResponseDto.builder() .total((int)followingPage.getTotalElements()) diff --git a/src/main/java/EatPic/spring/domain/user/dto/UserInfoDTO.java b/src/main/java/EatPic/spring/domain/user/dto/UserInfoDTO.java new file mode 100644 index 0000000..4f6dee9 --- /dev/null +++ b/src/main/java/EatPic/spring/domain/user/dto/UserInfoDTO.java @@ -0,0 +1,16 @@ +package EatPic.spring.domain.user.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class UserInfoDTO { + String email; + String nameId; + String nickName; +} diff --git a/src/main/java/EatPic/spring/domain/user/dto/request/LoginRequestDTO.java b/src/main/java/EatPic/spring/domain/user/dto/request/LoginRequestDTO.java new file mode 100644 index 0000000..48b5a8b --- /dev/null +++ b/src/main/java/EatPic/spring/domain/user/dto/request/LoginRequestDTO.java @@ -0,0 +1,19 @@ +package EatPic.spring.domain.user.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class LoginRequestDTO { + @NotBlank(message = "이메일을 임력해 주세요.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + private String email; + + @NotBlank(message = "비밀번호를 입력해 주세요.") + private String password; +} diff --git a/src/main/java/EatPic/spring/domain/user/dto/response/LoginResponseDTO.java b/src/main/java/EatPic/spring/domain/user/dto/response/LoginResponseDTO.java new file mode 100644 index 0000000..0d64cac --- /dev/null +++ b/src/main/java/EatPic/spring/domain/user/dto/response/LoginResponseDTO.java @@ -0,0 +1,19 @@ +package EatPic.spring.domain.user.dto.response; + +import EatPic.spring.domain.user.entity.Role; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Builder +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class LoginResponseDTO { + private Role role; + private Long userId; // DB에 저장된 유저 ID + private String email; // 회원가입 시 입력한 이메일 + private String accessToken; + private String refreshToken; +} diff --git a/src/main/java/EatPic/spring/domain/user/dto/response/SignupResponseDTO.java b/src/main/java/EatPic/spring/domain/user/dto/response/SignupResponseDTO.java index 27f7f54..f89973a 100644 --- a/src/main/java/EatPic/spring/domain/user/dto/response/SignupResponseDTO.java +++ b/src/main/java/EatPic/spring/domain/user/dto/response/SignupResponseDTO.java @@ -18,3 +18,4 @@ public class SignupResponseDTO { private Boolean notificationAgreed; // 알림 수신 동의 여부 private String message; // 예: "회원가입이 완료되었습니다." } + diff --git a/src/main/java/EatPic/spring/domain/user/entity/User.java b/src/main/java/EatPic/spring/domain/user/entity/User.java index 73f1446..21154d8 100644 --- a/src/main/java/EatPic/spring/domain/user/entity/User.java +++ b/src/main/java/EatPic/spring/domain/user/entity/User.java @@ -8,8 +8,11 @@ import jakarta.validation.constraints.Size; import java.time.LocalDateTime; import lombok.*; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import java.util.ArrayList; +import java.util.Collection; import java.util.List; @Entity @@ -48,9 +51,13 @@ public class User extends BaseEntity { @Column(name = "social_type", nullable = true) private SocialType socialType; - @Column(name = "refresh_token", length = 255) + @Column(name = "refresh_token", length = 512) private String refreshToken; + public void updateRefreshToken(String refreshToken) { + this.refreshToken = refreshToken; + } + @Column(name = "profile_image_url", length = 500) private String profileImageUrl; @@ -82,3 +89,4 @@ public void updateLastNotificationCheckAt(LocalDateTime time) { this.lastNotificationCheckAt = time; } } + diff --git a/src/main/java/EatPic/spring/domain/user/exception/UserErrorCode.java b/src/main/java/EatPic/spring/domain/user/exception/UserErrorCode.java deleted file mode 100644 index 63e9358..0000000 --- a/src/main/java/EatPic/spring/domain/user/exception/UserErrorCode.java +++ /dev/null @@ -1,35 +0,0 @@ -package EatPic.spring.domain.user.exception; - -import EatPic.spring.global.common.code.BaseErrorCode; -import EatPic.spring.global.common.code.ErrorReasonDTO; -import lombok.AllArgsConstructor; -import lombok.Getter; -import org.springframework.http.HttpStatus; - -@Getter -@AllArgsConstructor -public enum UserErrorCode implements BaseErrorCode { - USER_NOT_FOUND(false, HttpStatus.NOT_FOUND, "USER_404", "존재하지 않는 사용자입니다."); - - - private final boolean isSuccess; - private final HttpStatus httpStatus; - private final String code; - private final String message; - - @Override - public ErrorReasonDTO getReason() { - return ErrorReasonDTO.builder() - .isSuccess(isSuccess) - .httpStatus(httpStatus) - .code(code) - .message(message) - .build(); - } - - @Override - public ErrorReasonDTO getReasonHttpStatus() { - return getReason(); // 필요 시 httpStatus만 반환하도록 따로 구성 가능 - } - -} diff --git a/src/main/java/EatPic/spring/domain/user/repository/UserRepository.java b/src/main/java/EatPic/spring/domain/user/repository/UserRepository.java index 7d74426..107c104 100644 --- a/src/main/java/EatPic/spring/domain/user/repository/UserRepository.java +++ b/src/main/java/EatPic/spring/domain/user/repository/UserRepository.java @@ -11,13 +11,13 @@ public interface UserRepository extends JpaRepository { - boolean existsByEmail(String email); + boolean existsByEmail(String email); // 회원 가입 시, 이메일 중복 검사 boolean existsByNickname(String nickname); boolean existsByNameId(String nameId); - Optional findByEmail(String email); + Optional findByEmail(String email); // 로그인 시, 이메일로 유저 찾기 User findUserById(Long id); diff --git a/src/main/java/EatPic/spring/domain/user/service/UserService.java b/src/main/java/EatPic/spring/domain/user/service/UserService.java index 2ffb133..8082a43 100644 --- a/src/main/java/EatPic/spring/domain/user/service/UserService.java +++ b/src/main/java/EatPic/spring/domain/user/service/UserService.java @@ -1,94 +1,25 @@ package EatPic.spring.domain.user.service; -import EatPic.spring.domain.user.converter.UserConverter; +import EatPic.spring.domain.user.dto.*; +import EatPic.spring.domain.user.dto.request.LoginRequestDTO; +import EatPic.spring.domain.user.dto.response.LoginResponseDTO; import EatPic.spring.domain.user.dto.response.UserResponseDTO; -import EatPic.spring.domain.user.entity.User; import EatPic.spring.domain.user.dto.request.SignupRequestDTO; -import EatPic.spring.domain.user.entity.UserStatus; -import EatPic.spring.domain.user.mapping.UserBlock; -import EatPic.spring.domain.user.mapping.UserFollow; -import EatPic.spring.domain.user.repository.UserBlockRepository; -import EatPic.spring.domain.user.repository.UserFollowRepository; -import EatPic.spring.domain.user.repository.UserRepository; -import EatPic.spring.global.common.exception.handler.ExceptionHandler; -import lombok.RequiredArgsConstructor; -import org.springframework.data.domain.Page; -import org.springframework.data.domain.PageRequest; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; +import EatPic.spring.domain.user.dto.response.SignupResponseDTO; +import jakarta.servlet.http.HttpServletRequest; +public interface UserService { + // UserCommandService + SignupResponseDTO signup(SignupRequestDTO request); + LoginResponseDTO loginUser(LoginRequestDTO request); + UserResponseDTO.UserIconListResponseDto followingUserIconList(Long userId, int page, int size); + UserResponseDTO.ProfileDto getMyIcon(); + UserResponseDTO.UserBlockResponseDto blockUser(Long targetUserId); -import static EatPic.spring.domain.user.exception.UserErrorCode.*; + // UserQueryService + UserInfoDTO getUserInfo(HttpServletRequest request); -@Service -@RequiredArgsConstructor -public class UserService { +} - private final UserRepository userRepository; - private final UserBlockRepository userBlockRepository; - private final UserFollowRepository userFollowRepository; - private final PasswordEncoder passwordEncoder; - public User signup(SignupRequestDTO request) { - // 이메일 중복 검사 - if (userRepository.existsByEmail(request.getEmail())) { - throw new IllegalArgumentException("이미 사용 중인 이메일입니다."); - } - // 닉네임 중복 검사 - if (userRepository.existsByNickname(request.getNickname())) { - throw new IllegalArgumentException("이미 사용 중인 닉네임입니다."); - } - - // 아이디 중복 검사 - if (userRepository.existsByNameId(request.getNameId())) { - throw new IllegalArgumentException("이미 사용 중인 아이디입니다."); - } - - // 저장 - User user = User.builder() - .role(request.getRole()) - .email(request.getEmail()) - .password(passwordEncoder.encode(request.getPassword())) - .nameId(request.getNameId()) - .nickname(request.getNickname()) - .marketingAgreed(request.getMarketingAgreed() != null && request.getMarketingAgreed()) - .notificationAgreed(request.getNotificationAgreed() != null && request.getNotificationAgreed()) - .userStatus(UserStatus.ACTIVE) - .build(); - - return userRepository.save(user); - } - - @Transactional - public UserResponseDTO.UserBlockResponseDto blockUser(Long targetUserId) { - User user = userRepository.findUserById(1L); - User targetUser = userRepository.findById(targetUserId) - .orElseThrow(() -> new ExceptionHandler(USER_NOT_FOUND)); - - UserBlock userBlock = UserBlock.builder() - .user(user) - .blockedUser(targetUser) - .build(); - - userBlockRepository.save(userBlock); - - return UserConverter.toUserBlockResponseDto(userBlock); - } - - @Transactional - public UserResponseDTO.UserIconListResponseDto followingUserIconList(Long userId, int page, int size) { - User user = userRepository.findUserById(userId); - Page followingPage = userFollowRepository.findByUser(user, PageRequest.of(page, size)); - - return UserConverter.toUserIconListResponseDto(followingPage); - } - - @Transactional - public UserResponseDTO.ProfileDto getMyIcon() { - User me = userRepository.findUserById(1L); - return UserConverter.toProfileDto(me,true); - } - -} \ No newline at end of file diff --git a/src/main/java/EatPic/spring/domain/user/service/UserServiceImpl.java b/src/main/java/EatPic/spring/domain/user/service/UserServiceImpl.java new file mode 100644 index 0000000..24ee7a2 --- /dev/null +++ b/src/main/java/EatPic/spring/domain/user/service/UserServiceImpl.java @@ -0,0 +1,164 @@ +package EatPic.spring.domain.user.service; + +import EatPic.spring.domain.user.converter.UserConverter; +import EatPic.spring.domain.user.dto.*; +import EatPic.spring.domain.user.dto.request.LoginRequestDTO; +import EatPic.spring.domain.user.dto.request.SignupRequestDTO; +import EatPic.spring.domain.user.dto.response.LoginResponseDTO; +import EatPic.spring.domain.user.dto.response.SignupResponseDTO; +import EatPic.spring.domain.user.dto.response.UserResponseDTO; +import EatPic.spring.domain.user.entity.User; +import EatPic.spring.domain.user.entity.UserStatus; +import EatPic.spring.domain.user.mapping.UserBlock; +import EatPic.spring.domain.user.mapping.UserFollow; +import EatPic.spring.domain.user.repository.UserFollowRepository; +import EatPic.spring.domain.user.repository.UserRepository; +import EatPic.spring.domain.user.repository.UserBlockRepository; +import EatPic.spring.global.common.code.status.ErrorStatus; +import EatPic.spring.global.common.exception.handler.ExceptionHandler; +import EatPic.spring.global.config.jwt.JwtTokenProvider; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Collections; + +import static EatPic.spring.global.common.code.status.ErrorStatus.USER_NOT_FOUND; + +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService{ + + private final UserRepository userRepository; + private final UserFollowRepository userFollowRepository; + private final UserBlockRepository userBlockRepository; + private final UserBadgeService userBadgeService; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + //private final UserDetailsService userDetailsService; + + // 회원가입 + public SignupResponseDTO signup(SignupRequestDTO request) { + // 이메일 중복 검사 + if (userRepository.existsByEmail(request.getEmail())) { + throw new IllegalArgumentException("이미 사용 중인 이메일입니다."); + } + + // 닉네임 중복 검사 + if (userRepository.existsByNickname(request.getNickname())) { + throw new IllegalArgumentException("이미 사용 중인 닉네임입니다."); + } + + // 아이디 중복 검사 + if (userRepository.existsByNameId(request.getNameId())) { + throw new IllegalArgumentException("이미 사용 중인 아이디입니다."); + } + + // 저장 + User user = User.builder() + .role(request.getRole()) + .email(request.getEmail()) + .password(passwordEncoder.encode(request.getPassword())) + .nameId(request.getNameId()) + .nickname(request.getNickname()) + .marketingAgreed(request.getMarketingAgreed() != null && request.getMarketingAgreed()) + .notificationAgreed(request.getNotificationAgreed() != null && request.getNotificationAgreed()) + .userStatus(UserStatus.ACTIVE) + .build(); + + User savedUser = userRepository.save(user); + + // 뱃지 초기화 + userBadgeService.initializeUserBadges(savedUser); + + // DTO로 응답 생성 + return SignupResponseDTO.builder() + .role(request.getRole()) + .userId(savedUser.getId()) + .email(savedUser.getEmail()) + .nameId(savedUser.getNameId()) + .nickname(savedUser.getNickname()) + .marketingAgreed(savedUser.getMarketingAgreed()) + .notificationAgreed(savedUser.getNotificationAgreed()) + .message("회원가입이 완료되었습니다.") + .build(); + } + + // 로그인 + @Override + public LoginResponseDTO loginUser(LoginRequestDTO request){ + User user = userRepository.findByEmail(request.getEmail()) + .orElseThrow(()-> new ExceptionHandler(ErrorStatus.MEMBER_NOT_FOUND)); + + if(!passwordEncoder.matches(request.getPassword(), user.getPassword())) { + throw new ExceptionHandler(ErrorStatus.INVALID_PASSWORD); + } + + Authentication authentication = new UsernamePasswordAuthenticationToken( + user.getEmail(), null, + //Collections.emptyList() + Collections.singleton(() -> user.getRole().name()) + ); + + String accessToken = jwtTokenProvider.generateToken(authentication); + String refreshToken = jwtTokenProvider.generateRefreshToken(user.getEmail()); + + // refreshToken -> DB에 저장 + user.updateRefreshToken(refreshToken); + userRepository.save(user); + + return UserConverter.toLoginResultDTO(user, accessToken, refreshToken); + } + + // JWT 사용자 정보 조회 + @Override + @Transactional(readOnly = true) + public UserInfoDTO getUserInfo(HttpServletRequest request) { + Authentication authentication = jwtTokenProvider.extractAuthentication(request); + String email = authentication.getName(); + + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new ExceptionHandler(ErrorStatus.MEMBER_NOT_FOUND)); + return UserConverter.toUserInfoDTO(user); + } + + // 팔로잉한 유저의 프로필 아이콘 목록 조회 + @Override + public UserResponseDTO.UserIconListResponseDto followingUserIconList(Long userId, int page, int size) { + User user = userRepository.findUserById(userId); + Page followingPage = userFollowRepository.findByUser(user, PageRequest.of(page, size)); + + return UserConverter.toUserIconListResponseDto(followingPage); + } + + // 내 프로필 아이콘 조회 + @Override + public UserResponseDTO.ProfileDto getMyIcon() { + User me = userRepository.findUserById(1L); + return UserConverter.toProfileDto(me,true); + } + + // 유저 차단 + @Transactional + public UserResponseDTO.UserBlockResponseDto blockUser(Long targetUserId) { + User user = userRepository.findUserById(1L); + User targetUser = userRepository.findById(targetUserId) + .orElseThrow(() -> new ExceptionHandler(USER_NOT_FOUND)); + + UserBlock userBlock = UserBlock.builder() + .user(user) + .blockedUser(targetUser) + .build(); + + userBlockRepository.save(userBlock); + + return UserConverter.toUserBlockResponseDto(userBlock); + } +} \ No newline at end of file diff --git a/src/main/java/EatPic/spring/global/common/BaseErrorCode.java b/src/main/java/EatPic/spring/global/common/BaseErrorCode.java new file mode 100644 index 0000000..b600981 --- /dev/null +++ b/src/main/java/EatPic/spring/global/common/BaseErrorCode.java @@ -0,0 +1,8 @@ +package EatPic.spring.global.common; + +import EatPic.spring.global.common.code.ErrorReasonDTO; + +public interface BaseErrorCode { + ErrorReasonDTO getReason(); + ErrorReasonDTO getReasonHttpStatus(); +} diff --git a/src/main/java/EatPic/spring/global/common/code/BaseCode.java b/src/main/java/EatPic/spring/global/common/code/BaseCode.java index 228d8b1..bb986a3 100644 --- a/src/main/java/EatPic/spring/global/common/code/BaseCode.java +++ b/src/main/java/EatPic/spring/global/common/code/BaseCode.java @@ -1,7 +1,7 @@ package EatPic.spring.global.common.code; public interface BaseCode { - public ReasonDTO getReason(); - public ReasonDTO getReasonHttpStatus(); + ReasonDTO getReason(); + ReasonDTO getReasonHttpStatus(); } diff --git a/src/main/java/EatPic/spring/global/common/code/ErrorReasonDTO.java b/src/main/java/EatPic/spring/global/common/code/ErrorReasonDTO.java index 455dffa..c74a71c 100644 --- a/src/main/java/EatPic/spring/global/common/code/ErrorReasonDTO.java +++ b/src/main/java/EatPic/spring/global/common/code/ErrorReasonDTO.java @@ -7,8 +7,8 @@ @Getter @Builder public class ErrorReasonDTO { - private final HttpStatus httpStatus; + private final boolean isSuccess; private final String code; private final String message; diff --git a/src/main/java/EatPic/spring/global/common/code/ReasonDTO.java b/src/main/java/EatPic/spring/global/common/code/ReasonDTO.java index 9378d03..7d49aea 100644 --- a/src/main/java/EatPic/spring/global/common/code/ReasonDTO.java +++ b/src/main/java/EatPic/spring/global/common/code/ReasonDTO.java @@ -7,8 +7,8 @@ @Getter @Builder public class ReasonDTO { - private final HttpStatus httpStatus; + private final boolean isSuccess; private final String code; private final String message; diff --git a/src/main/java/EatPic/spring/global/common/code/status/ErrorStatus.java b/src/main/java/EatPic/spring/global/common/code/status/ErrorStatus.java index 3047457..c60a434 100644 --- a/src/main/java/EatPic/spring/global/common/code/status/ErrorStatus.java +++ b/src/main/java/EatPic/spring/global/common/code/status/ErrorStatus.java @@ -9,7 +9,6 @@ @Getter @AllArgsConstructor public enum ErrorStatus implements BaseErrorCode { - // 기본 에러 _BAD_REQUEST(HttpStatus.BAD_REQUEST, "COMMON_400", "잘못된 요청입니다."), _UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "COMMON_401", "인증이 필요합니다."), @@ -36,6 +35,7 @@ public enum ErrorStatus implements BaseErrorCode { // 카드 관련 응답 CARD_NOT_FOUND(HttpStatus.NOT_FOUND, "CARD_001", "해당 카드는 존재하지 않는 카드입니다."), + // 같은 날짜에 같은 meal 중복 에러 DUPLICATE_MEAL_CARD(HttpStatus.CONFLICT, "CARD_002", "이미 같은 날짜와 같은 식사 유형의 카드가 존재합니다."), CARD_UPDATE_FORBIDDEN(HttpStatus.FORBIDDEN, "CARD_003", "해당 카드를 수정할 수 있는 권한이 없습니다."), @@ -43,7 +43,6 @@ public enum ErrorStatus implements BaseErrorCode { ALREADY_BOOKMARKED(HttpStatus.CONFLICT, "CARD_006", "이미 저장된 카드입니다."), BOOKMARK_NOT_FOUND(HttpStatus.NOT_FOUND, "CARD_005", "카드를 저장한 기록이 없습니다"), IMAGE_REQUIRED(HttpStatus.BAD_REQUEST, "CARD_007", "카드에 이미지를 첨부해주세요."), - // 댓글 관련 응답 COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "COMMENT_001", "해당 댓글은 존재하지 않는 댓글입니다."), @@ -54,7 +53,12 @@ public enum ErrorStatus implements BaseErrorCode { // 유저 관련 응답 USER_NOT_FOUND(HttpStatus.BAD_REQUEST, "USER_001", "해당 유저는 존재하지 않는 유저입니다."), - CARD_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "USER_005", "해당 카드를 삭제할 권한이 없습니다."); + CARD_DELETE_FORBIDDEN(HttpStatus.FORBIDDEN, "USER_005", "해당 카드를 삭제할 권한이 없습니다."), + MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "USER_404", "사용자가 없습니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "USER_401", "비밀번호가 불일치합니다."), + INVALID_TOKEN(HttpStatus.BAD_REQUEST, "USER_403", "유효하지 않은 토큰입니다."), + DUPLICATE_JOIN_REQUEST(HttpStatus.BAD_REQUEST, "USER_400", "해당 이메일로 이미 가입된 사용자가 존재합니다."); + private final HttpStatus httpStatus; private final String code; private final String message; diff --git a/src/main/java/EatPic/spring/global/config/Properties/Constants.java b/src/main/java/EatPic/spring/global/config/Properties/Constants.java new file mode 100644 index 0000000..dd2b799 --- /dev/null +++ b/src/main/java/EatPic/spring/global/config/Properties/Constants.java @@ -0,0 +1,6 @@ +package EatPic.spring.global.config.Properties; + +public class Constants { + public static final String AUTH_HEADER = "Authorization"; + public static final String TOKEN_PREFIX = "Bearer "; +} diff --git a/src/main/java/EatPic/spring/global/config/Properties/JwtProperties.java b/src/main/java/EatPic/spring/global/config/Properties/JwtProperties.java new file mode 100644 index 0000000..25905e0 --- /dev/null +++ b/src/main/java/EatPic/spring/global/config/Properties/JwtProperties.java @@ -0,0 +1,17 @@ +package EatPic.spring.global.config.Properties; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@Getter +@Setter +@ConfigurationProperties(prefix = "spring.jwt") +public class JwtProperties { + private String secret; + + private long accessTokenValidity; + private long refreshTokenValidity; +} diff --git a/src/main/java/EatPic/spring/global/config/SecurityConfig.java b/src/main/java/EatPic/spring/global/config/SecurityConfig.java index ea9c4da..058b939 100644 --- a/src/main/java/EatPic/spring/global/config/SecurityConfig.java +++ b/src/main/java/EatPic/spring/global/config/SecurityConfig.java @@ -1,5 +1,7 @@ package EatPic.spring.global.config; +import EatPic.spring.global.config.jwt.JwtAuthenticationFilter; +import EatPic.spring.global.config.jwt.JwtTokenProvider; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -14,6 +16,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @@ -21,8 +24,8 @@ @EnableMethodSecurity(prePostEnabled = true) public class SecurityConfig { - //private final CorsConfig corsConfig; - //private final JwtAuthenticationFilter jwtAuthenticationFilter; + // private final CorsConfig corsConfig; + // private final JwtAuthenticationFilter jwtAuthenticationFilter; @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -53,6 +56,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .requestMatchers("/api/**").permitAll() // ADMIN 권한 .requestMatchers("/admin/**").hasRole("ADMIN") + // ADMIN 보호 구간 + //.requestMatchers("/api/admin", "/api/admin/**").hasRole("ADMIN") + //.requestMatchers("/api/user", "/api/user/**").hasAnyRole("USER","ADMIN") // 그 외 모든 요청은 모두 인증 필요 .anyRequest().authenticated()); //.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); @@ -75,4 +81,12 @@ public AuthenticationManager authenticationManager( AuthenticationConfiguration authenticationConfiguration) throws Exception { return authenticationConfiguration.getAuthenticationManager(); } -} \ No newline at end of file + +// @Bean +// public JwtAuthenticationFilter jwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) { +// return new JwtAuthenticationFilter(jwtTokenProvider); +// } +} + + + diff --git a/src/main/java/EatPic/spring/global/config/SwaggerConfig.java b/src/main/java/EatPic/spring/global/config/SwaggerConfig.java index 1aa1c20..498e287 100644 --- a/src/main/java/EatPic/spring/global/config/SwaggerConfig.java +++ b/src/main/java/EatPic/spring/global/config/SwaggerConfig.java @@ -1,7 +1,10 @@ package EatPic.spring.global.config; +import io.swagger.v3.oas.models.Components; import io.swagger.v3.oas.models.OpenAPI; import io.swagger.v3.oas.models.info.Info; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import io.swagger.v3.oas.models.servers.Server; import org.springdoc.core.models.GroupedOpenApi; import org.springframework.context.annotation.Bean; @@ -17,10 +20,20 @@ public OpenAPI EatpicAPI() { .description("EatpicAPI 명세서") .version("1.0.0"); + String jwtSchemeName = "JWT TOKEN"; + SecurityRequirement securityRequirement = new SecurityRequirement().addList(jwtSchemeName); + Components components = new Components() + .addSecuritySchemes(jwtSchemeName, new SecurityScheme() + .name(jwtSchemeName) + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT")); + return new OpenAPI() .info(info) - .addServersItem(new Server().url("/")); - // 인증 관련(SecurityRequirement, Components) 설정 모두 제거 + .addServersItem(new Server().url("/")) + .addSecurityItem(securityRequirement) + .components(components); } @Bean diff --git a/src/main/java/EatPic/spring/global/config/jwt/JwtAuthenticationFilter.java b/src/main/java/EatPic/spring/global/config/jwt/JwtAuthenticationFilter.java new file mode 100644 index 0000000..3e83ef2 --- /dev/null +++ b/src/main/java/EatPic/spring/global/config/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,42 @@ +package EatPic.spring.global.config.jwt; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; +import EatPic.spring.global.config.Properties.Constants; +import java.io.IOException; + +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private final JwtTokenProvider jwtTokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain) + throws ServletException, IOException { + + String token = JwtTokenProvider.resolveToken(request); // 또는 내부 resolveToken + if (StringUtils.hasText(token) && jwtTokenProvider.validateToken(token)) { + Authentication authentication = jwtTokenProvider.getAuthentication(token); + SecurityContextHolder.getContext().setAuthentication(authentication); + } + filterChain.doFilter(request, response); + } + + private String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(Constants.AUTH_HEADER); + + if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(Constants.TOKEN_PREFIX)) { + return bearerToken.substring(Constants.TOKEN_PREFIX.length()); + } + + return null; + } +} diff --git a/src/main/java/EatPic/spring/global/config/jwt/JwtTokenProvider.java b/src/main/java/EatPic/spring/global/config/jwt/JwtTokenProvider.java new file mode 100644 index 0000000..9021ffe --- /dev/null +++ b/src/main/java/EatPic/spring/global/config/jwt/JwtTokenProvider.java @@ -0,0 +1,134 @@ +package EatPic.spring.global.config.jwt; + +import EatPic.spring.domain.user.repository.UserRepository; +import EatPic.spring.global.common.code.status.ErrorStatus; +import EatPic.spring.global.common.exception.handler.ExceptionHandler; +import EatPic.spring.global.config.Properties.Constants; +import EatPic.spring.global.config.Properties.JwtProperties; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + + +import java.security.Key; +import java.util.Collections; +import java.util.Date; +import java.util.List; +import java.util.stream.Collectors; + +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final JwtProperties jwtProperties; + private final UserRepository userRepository; + private static final String ROLES = "roles"; + + private Key getSigningKey() { + return Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes()); + } + + // JWT Access Token을 생성하고 반환 + public String generateToken(Authentication authentication) { + String email = authentication.getName(); + + List roles = authentication.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .collect(Collectors.toList()); + + // 🔹 권한이 비어 있으면 DB에서 사용자 role을 읽어 보정 (임시 해결) + if (roles.isEmpty()) { + var user = userRepository.findByEmail(email).orElse(null); + if (user != null && user.getRole() != null) { + roles = java.util.List.of("ROLE_" + user.getRole().name()); + } + } + + return Jwts.builder() + .setSubject(email) + .claim(ROLES, roles) // 🔹 roles 클레임 추가 + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getAccessTokenValidity())) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + // refreshToeken용 인증 없이 email만 + public String generateRefreshToken(String email) { + return Jwts.builder() + .setSubject(email) + .setIssuedAt(new Date()) + .setExpiration(new Date(System.currentTimeMillis() + jwtProperties.getRefreshTokenValidity())) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + + // WT 토큰이 유효한지 검증 + public boolean validateToken(String token) { + try { + Jwts.parser() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + // JWT 토큰에서 인증 정보를 추출해서 Spring Security의 Authentication 객체로 변환 + public Authentication getAuthentication(String token) { + Claims claims = Jwts.parser() // parserBuilder() 사용 + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + + String email = claims.getSubject(); + + @SuppressWarnings("unchecked") + List roleStrings = claims.get(ROLES) instanceof List + ? (List) claims.get(ROLES) + : java.util.Collections.emptyList(); + + List authorities = roleStrings.stream() + // hasRole("ADMIN")를 쓰면 내부적으로 "ROLE_ADMIN"을 찾음 → 접두 보장 + .map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r) + .map(SimpleGrantedAuthority::new) + .toList(); + + org.springframework.security.core.userdetails.User principal = + new org.springframework.security.core.userdetails.User(email, "", authorities); + + return new org.springframework.security.authentication.UsernamePasswordAuthenticationToken( + principal, null, authorities); + } + + public static String resolveToken(HttpServletRequest request) { + String bearerToken = request.getHeader(Constants.AUTH_HEADER); + if(StringUtils.hasText(bearerToken) && bearerToken.startsWith(Constants.TOKEN_PREFIX)) { + return bearerToken.substring(Constants.TOKEN_PREFIX.length()); + } + return null; + } + + // HttpServletRequest 에서 토큰 값을 추출 + // getAuthentication 메소드를 이용해서 Spring Security의 Authentication 객체로 변환 + public Authentication extractAuthentication(HttpServletRequest request){ + String accessToken = resolveToken(request); + if(accessToken == null || !validateToken(accessToken)) { + throw new ExceptionHandler(ErrorStatus.INVALID_TOKEN); + } + return getAuthentication(accessToken); + } +}