diff --git a/build.gradle b/build.gradle index 70c0be39..b4429de8 100644 --- a/build.gradle +++ b/build.gradle @@ -33,7 +33,15 @@ dependencies { testImplementation 'com.h2database:h2' //security - //implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-security' + + // JWT + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' + + // oauth + implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' //swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.1' diff --git a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java index 42f94925..ed73e900 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/booking/entity/Booking.java @@ -10,7 +10,8 @@ import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; import com.eatsfine.eatsfine.domain.user.entity.User; -import com.eatsfine.eatsfine.global.entity.BaseEntity; + +import com.eatsfine.eatsfine.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java index b9a332fb..b676360c 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/businesshours/entity/BusinessHours.java @@ -1,7 +1,7 @@ package com.eatsfine.eatsfine.domain.businesshours.entity; import com.eatsfine.eatsfine.domain.store.entity.Store; -import com.eatsfine.eatsfine.global.entity.BaseEntity; +import com.eatsfine.eatsfine.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java index cfd97401..a024e442 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/image/status/ImageErrorStatus.java @@ -15,6 +15,7 @@ public enum ImageErrorStatus implements BaseErrorCode { _IMAGE_NOT_FOUND(HttpStatus.NOT_FOUND, "IMAGE404", "해당하는 이미지가 존재하지 않습니다."), _INVALID_IMAGE_KEY(HttpStatus.BAD_REQUEST, "IMAGE4003", "유효하지 않은 이미지 키입니다."), _INVALID_S3_DIRECTORY(HttpStatus.BAD_REQUEST, "IMAGE4004", "유효하지 않은 S3 디렉토리입니다."), + FILE_TOO_LARGE(HttpStatus.BAD_REQUEST, "IMAGE4005", "첨부하는 이미지의 크기가 너무 큽니다.") ; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java b/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java index 83119ee0..3c38e435 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/menu/entity/Menu.java @@ -2,7 +2,8 @@ import com.eatsfine.eatsfine.domain.menu.enums.MenuCategory; import com.eatsfine.eatsfine.domain.store.entity.Store; -import com.eatsfine.eatsfine.global.entity.BaseEntity; + +import com.eatsfine.eatsfine.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.SQLDelete; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java index 710c504d..d305a192 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/payment/entity/Payment.java @@ -5,7 +5,7 @@ import com.eatsfine.eatsfine.domain.payment.enums.PaymentProvider; import com.eatsfine.eatsfine.domain.payment.enums.PaymentStatus; import com.eatsfine.eatsfine.domain.payment.enums.PaymentType; -import com.eatsfine.eatsfine.global.entity.BaseEntity; +import com.eatsfine.eatsfine.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java index 1d56603d..61d4b728 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/store/entity/Store.java @@ -13,7 +13,8 @@ import com.eatsfine.eatsfine.domain.user.entity.User; import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; -import com.eatsfine.eatsfine.global.entity.BaseEntity; + +import com.eatsfine.eatsfine.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.BatchSize; @@ -191,4 +192,4 @@ public void updateBasicInfo(StoreReqDto.StoreUpdateDto dto) { } } -} +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java index 0de743cd..2eaca4c1 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/storetable/entity/StoreTable.java @@ -2,7 +2,8 @@ import com.eatsfine.eatsfine.domain.storetable.enums.SeatsType; import com.eatsfine.eatsfine.domain.table_layout.entity.TableLayout; -import com.eatsfine.eatsfine.global.entity.BaseEntity; + +import com.eatsfine.eatsfine.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.SQLDelete; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java index 4d45ea43..14b6fa17 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/table_layout/entity/TableLayout.java @@ -2,7 +2,7 @@ import com.eatsfine.eatsfine.domain.store.entity.Store; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; -import com.eatsfine.eatsfine.global.entity.BaseEntity; +import com.eatsfine.eatsfine.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; import org.hibernate.annotations.SQLDelete; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/entity/TableBlock.java b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/entity/TableBlock.java index f38ab2f8..0c50b23f 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/tableblock/entity/TableBlock.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableblock/entity/TableBlock.java @@ -1,7 +1,7 @@ package com.eatsfine.eatsfine.domain.tableblock.entity; import com.eatsfine.eatsfine.domain.storetable.entity.StoreTable; -import com.eatsfine.eatsfine.global.entity.BaseEntity; +import com.eatsfine.eatsfine.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java index d13b2370..8d5e90c1 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/tableimage/entity/TableImage.java @@ -1,7 +1,7 @@ package com.eatsfine.eatsfine.domain.tableimage.entity; import com.eatsfine.eatsfine.domain.store.entity.Store; -import com.eatsfine.eatsfine.global.entity.BaseEntity; +import com.eatsfine.eatsfine.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; diff --git a/src/main/java/com/eatsfine/eatsfine/domain/term/entity/Term.java b/src/main/java/com/eatsfine/eatsfine/domain/term/entity/Term.java new file mode 100644 index 00000000..cf0f4970 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/term/entity/Term.java @@ -0,0 +1,35 @@ +package com.eatsfine.eatsfine.domain.term.entity; + +import com.eatsfine.eatsfine.domain.user.entity.User; +import com.eatsfine.eatsfine.global.common.BaseEntity; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "term") +public class Term extends BaseEntity { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @OneToOne(fetch = FetchType.LAZY, cascade = CascadeType.REMOVE, orphanRemoval = true) + @JoinColumn(name = "user_id", nullable = false, unique = true) + private User user; + + @Column(name = "tos_consent", nullable = false) + private Boolean tosConsent; + + @Column(name = "privacy_consent", nullable = false) + private Boolean privacyConsent; + + @Column(name = "marketing_consent", nullable = false) + private Boolean marketingConsent; + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/term/repository/TermRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/term/repository/TermRepository.java new file mode 100644 index 00000000..1c1c8d7f --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/term/repository/TermRepository.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.term.repository; + +import com.eatsfine.eatsfine.domain.term.entity.Term; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface TermRepository extends JpaRepository { + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/controller/UserController.java b/src/main/java/com/eatsfine/eatsfine/domain/user/controller/UserController.java index 0c538442..91e28b39 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/controller/UserController.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/controller/UserController.java @@ -1,4 +1,134 @@ package com.eatsfine.eatsfine.domain.user.controller; + +import com.eatsfine.eatsfine.domain.user.dto.request.UserRequestDto; +import com.eatsfine.eatsfine.domain.user.dto.response.UserResponseDto; +import com.eatsfine.eatsfine.domain.user.exception.UserException; +import com.eatsfine.eatsfine.domain.user.service.UserService; +import com.eatsfine.eatsfine.domain.user.status.UserErrorStatus; +import com.eatsfine.eatsfine.global.apiPayload.ApiResponse; +import com.eatsfine.eatsfine.global.auth.AuthCookieProvider; +import com.eatsfine.eatsfine.global.config.jwt.JwtTokenProvider; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.RequestBody; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseCookie; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; + + +@Tag(name = "User", description = "회원 관리 API") +@Slf4j +@RestController +@RequiredArgsConstructor public class UserController { + private final UserService userService; + private final JwtTokenProvider jwtTokenProvider; + private final AuthCookieProvider authCookieProvider; + + @PostMapping("/api/auth/signup") + @Operation(summary = "회원가입 API", description = "회원가입을 처리하는 API입니다.") + public ResponseEntity signup(@RequestBody @Valid UserRequestDto.JoinDto joinDto) { + UserResponseDto.JoinResultDto result = userService.signup(joinDto); + return ResponseEntity.ok(result); + } + + @PostMapping("/api/auth/login") + @Operation(summary = "로그인 API", description = "사용자 로그인을 처리하는 API입니다.") + public ResponseEntity> login(@RequestBody UserRequestDto.LoginDto loginDto) { + UserResponseDto.LoginResponseDto loginResult = userService.login(loginDto); + + if (loginResult.getRefreshToken() == null || loginResult.getRefreshToken().isBlank()) { + throw new UserException(UserErrorStatus.REFRESH_TOKEN_NOT_ISSUED); + } + + ResponseCookie refreshCookie = authCookieProvider.refreshTokenCookie(loginResult.getRefreshToken()); + + UserResponseDto.LoginResponseDto body = UserResponseDto.LoginResponseDto.builder() + .id(loginResult.getId()) + .accessToken(loginResult.getAccessToken()) + .refreshToken(null) + .build(); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, refreshCookie.toString()) + .body(ApiResponse.onSuccess(body)); + } + + @GetMapping("/api/v1/member/info") + @Operation( + summary = "유저 내 정보 조회 API - 인증 필요", + description = "유저가 내 정보를 조회하는 API입니다.", + security = {@SecurityRequirement(name = "JWT")} + ) + public ApiResponse getMyInfo(HttpServletRequest request) { + return ApiResponse.onSuccess(userService.getMemberInfo(request)); + } + + + @PatchMapping(value = "/api/v1/member/info") + @Operation( + summary = "닉네임/전화번호 수정 API - 인증 필요", + description = "닉네임/전화번호만 수정합니다. (JSON)", + security = {@SecurityRequirement(name = "JWT")} + ) + public ResponseEntity> updateMyInfoText( + @RequestBody @Valid UserRequestDto.UpdateDto updateDto, HttpServletRequest request + ) { + String result = userService.updateMemberInfo(updateDto, null, request); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + + @PutMapping( + value = "/api/v1/member/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "프로필 이미지 수정 API - 인증 필요", + description = "프로필 이미지만 수정합니다. (multipart/form-data)", + security = {@SecurityRequirement(name = "JWT")} + ) + public ResponseEntity> updateProfileImage( + @RequestPart(value = "profileImage") MultipartFile profileImage, + HttpServletRequest request + ) { + String result = userService.updateMemberInfo(null, profileImage, request); + return ResponseEntity.ok(ApiResponse.onSuccess(result)); + } + + + @DeleteMapping("/api/auth/withdraw") + @Operation( + summary = "회원 탈퇴 API - 인증 필요", + description = "회원 탈퇴 기능 API입니다.", + security = {@SecurityRequirement(name = "JWT")} + ) + public ResponseEntity withdraw(HttpServletRequest request) { + userService.withdraw(request); + return ResponseEntity.ok(ApiResponse.onSuccess("회원 탈퇴가 완료되었습니다.")); + } + + + @DeleteMapping("/api/auth/logout") + @Operation( + summary = "회원 로그아웃 API - 인증 필요", + description = "회원 로그아웃 기능 API입니다.", + security = {@SecurityRequirement(name = "JWT")} + ) + public ResponseEntity> logout(HttpServletRequest request) { + userService.logout(request); + ResponseCookie clearCookie = authCookieProvider.clearRefreshTokenCookie(); + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, clearCookie.toString()) + .body(ApiResponse.onSuccess("로그아웃이 되었습니다.")); + } + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/converter/UserConverter.java b/src/main/java/com/eatsfine/eatsfine/domain/user/converter/UserConverter.java index ddce98db..81c3c0d0 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/converter/UserConverter.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/converter/UserConverter.java @@ -1,4 +1,102 @@ package com.eatsfine.eatsfine.domain.user.converter; +import com.eatsfine.eatsfine.domain.term.entity.Term; +import com.eatsfine.eatsfine.domain.user.dto.request.UserRequestDto; +import com.eatsfine.eatsfine.domain.user.dto.response.UserResponseDto; +import com.eatsfine.eatsfine.domain.user.entity.User; +import com.eatsfine.eatsfine.domain.user.enums.SocialType; + +import java.time.LocalDateTime; + +import static com.eatsfine.eatsfine.domain.user.enums.Role.ROLE_CUSTOMER; + public class UserConverter { + + public static UserResponseDto.JoinResultDto toJoinResult(User user) { + return UserResponseDto.JoinResultDto.builder() + .id(user.getId()) + .createdAt(user.getCreatedAt()) + .build(); + } + + + //로그인 응답 변환 + public static UserResponseDto.LoginResponseDto toLoginResponse(User user, String accessToken) { + return UserResponseDto.LoginResponseDto.builder() + .id(user.getId()) + .accessToken(accessToken) + .refreshToken(null) + .build(); + } + + + // 유저 정보 조회 응답 변환 + public static UserResponseDto.UserInfoDto toUserInfo(User user) { + return UserResponseDto.UserInfoDto.builder() + .id(user.getId()) + .profileImage(user.getProfileImage()) + .email(user.getEmail()) + .nickName(user.getNickName()) + .phoneNumber(user.getPhoneNumber()) + .build(); + } + + + //유저 정보 수정 응답 변환 + public static UserResponseDto.UpdateResponseDto toUpdateResponse(User user) { + return UserResponseDto.UpdateResponseDto.builder() + .profileImage(user.getProfileImage()) + .email(user.getEmail()) + .nickName(user.getNickName()) + .phoneNumber(user.getPhoneNumber()) + .build(); + } + + + // 비밀번호 변경 응답 변환 + public static UserResponseDto.UpdatePasswordDto toUpdatePasswordResponse(boolean changed, LocalDateTime changedAt, String message) { + return UserResponseDto.UpdatePasswordDto.builder() + .changed(changed) + .changedAt(changedAt) + .message(message) + .build(); + } + + + public static User toUser(UserRequestDto.JoinDto dto, String encodedPassword) { + return User.builder() + .nickName(dto.getNickName()) + .email(dto.getEmail()) + .phoneNumber(dto.getPhoneNumber()) + .password(encodedPassword) + .role(ROLE_CUSTOMER) // 기본 권한 + .build(); + } + + public static Term toUserTerm(UserRequestDto.JoinDto dto, User user) { + return Term.builder() + .user(user) // 생성된 유저와 매핑 + .tosConsent(dto.getTosConsent()) // 서비스 이용약관 동의 + .privacyConsent(dto.getPrivacyConsent()) // 개인정보 처리방침 동의 + .marketingConsent(dto.getMarketingConsent()) // 마케팅 수신 동의 + .build(); + } + + + /* + 소셜 유저 생성 (최초 소셜 가입 등) + -소셜 로그인에서 email/nickname/phoneNumber 등을 확보한 후 엔티티 생성에 사용 + */ + public static User toSocialUser(String email, String nickName, String phoneNumber, String socialId, SocialType socialType) { + + return User.builder() + .email(email) + .nickName(nickName) + .phoneNumber(phoneNumber) + .socialId(socialId) + .socialType(socialType) + .role(ROLE_CUSTOMER) + .build(); + } + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/dto/UserRequest.java b/src/main/java/com/eatsfine/eatsfine/domain/user/dto/UserRequest.java deleted file mode 100644 index 9528c2e4..00000000 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/dto/UserRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.eatsfine.eatsfine.domain.user.dto; - -public class UserRequest { -} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/dto/request/UserRequestDto.java b/src/main/java/com/eatsfine/eatsfine/domain/user/dto/request/UserRequestDto.java new file mode 100644 index 00000000..e368ada3 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/dto/request/UserRequestDto.java @@ -0,0 +1,88 @@ +package com.eatsfine.eatsfine.domain.user.dto.request; + +import com.eatsfine.eatsfine.global.validator.annotation.PasswordMatch; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; +import lombok.Getter; +import lombok.Setter; + +public class UserRequestDto { + + @PasswordMatch + @Getter + public static class JoinDto{ + + @NotBlank(message = "이름은 필수입니다.") + private String nickName; // 이름 + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "유효한 이메일 형식이어야 합니다.") + private String email; // 이메일 + + @NotBlank(message = "휴대전화 번호는 필수입니다.") + @Pattern(regexp = "^010\\d{8}$", message = "휴대전화 번호는 010으로 시작하는 11자리 숫자여야 합니다.") + private String phoneNumber; // 휴대전화 번호 + + @NotBlank(message = "비밀번호는 필수 입니다.") + @Pattern( + regexp = "^(?=(.*[a-zA-Z].*[0-9])|(?=.*[a-zA-Z].*[!@#$%^&*])|(?=.*[0-9].*[!@#$%^&*]))[a-zA-Z0-9!@#$%^&*]{8,20}$", + message = "비밀번호는 영문, 숫자, 특수문자 중 2가지 이상 조합이며, 8자 ~20자 이내 이어야 합니다." + ) + private String password; + + @NotBlank(message = "비밀번호 확인은 필수입니다.") + private String passwordConfirm; // 비밀번호 확인 + + @AssertTrue(message = "이용약관에 동의해야 합니다.") + @Schema(description = "서비스 이용약관 동의 여부 (필수)", example = "true") + private Boolean tosConsent; + + @AssertTrue(message = "개인정보 처리방침에 동의해야 합니다.") + @Schema(description = "개인정보 수집 및 이용 동의 여부 (필수)", example = "true") + private Boolean privacyConsent; + + @NotNull(message = "마케팅 정보 수신에 동의합니다") + @Schema(description = "마케팅 정보 수신 동의 여부 (선택)", example = "false") + private Boolean marketingConsent; + + } + + @Getter + public static class LoginDto { + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "유효한 이메일 형식이어야 합니다.") + private String email; + + @NotBlank(message = "비밀번호는 필수입니다.") + private String password; + } + + @Getter + @Setter + public static class UpdateDto { + private String email; + private String nickName; + private String phoneNumber; + } + + @Getter + @PasswordMatch(passwordField = "newPassword", confirmField = "newPasswordConfirm") + public static class ChangePasswordDto { + + @NotBlank(message = "현재 비밀번호는 필수입니다.") + @Schema(description = "현재 비밀번호", example = "CurrentPw!123") + private String currentPassword; + + @NotBlank(message = "새 비밀번호는 필수입니다.") + @Pattern( + regexp = "^(?=(.*[a-zA-Z].*[0-9])|(?=.*[a-zA-Z].*[!@#$%^&*])|(?=.*[0-9].*[!@#$%^&*]))[a-zA-Z0-9!@#$%^&*]{8,20}$", + message = "새 비밀번호는 영문, 숫자, 특수문자 중 2가지 이상 조합이며, 8자 ~20자 이내 이어야 합니다." + ) + @Schema(description = "새 비밀번호", example = "NewPw!1234") + private String newPassword; + + @NotBlank(message = "새 비밀번호 확인은 필수입니다.") + @Schema(description = "새 비밀번호 확인", example = "NewPw!1234") + private String newPasswordConfirm; + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/dto/response/UserResponseDto.java b/src/main/java/com/eatsfine/eatsfine/domain/user/dto/response/UserResponseDto.java new file mode 100644 index 00000000..a81b0338 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/dto/response/UserResponseDto.java @@ -0,0 +1,66 @@ +package com.eatsfine.eatsfine.domain.user.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +import java.time.LocalDateTime; + +public class UserResponseDto { + + @Builder + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class JoinResultDto{ + private Long id; + private LocalDateTime createdAt; + } + + @Builder + @Getter + @AllArgsConstructor + @NoArgsConstructor + public static class LoginResponseDto{ + private Long id; + private String accessToken; + private String refreshToken; + } + + @Builder + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class UserInfoDto{ + private Long id; + private String profileImage; + private String email; + private String nickName; + private String phoneNumber; + } + + @Getter + @Setter + @Builder + public static class UpdateResponseDto{ + private String profileImage; + private String email; + private String nickName; + private String phoneNumber; + } + + @Getter + @Builder + @AllArgsConstructor + public static class UpdatePasswordDto { + + @Schema(description = "비밀번호 변경 완료 여부", example = "true") + private boolean changed; + + @Schema(description = "비밀번호 변경 완료 시각", example = "2026-01-30T18:25:43") + private LocalDateTime changedAt; + + @Schema(description = "응답 메시지", example = "비밀번호가 성공적으로 변경되었습니다.") + private String message; + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java b/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java index d6279e57..818a0f02 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/entity/User.java @@ -1,5 +1,8 @@ package com.eatsfine.eatsfine.domain.user.entity; +import com.eatsfine.eatsfine.domain.user.enums.Role; +import com.eatsfine.eatsfine.domain.user.enums.SocialType; +import com.eatsfine.eatsfine.global.common.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -10,9 +13,54 @@ @AllArgsConstructor @Builder @Table(name = "users") -public class User { +public class User extends BaseEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + + @Column(nullable = false, length = 20) + private String nickName; + + @Column(nullable = false, unique = true) + private String email; + + private String password; + + @Column(nullable = false, length = 20) + private String phoneNumber; + + @Enumerated(EnumType.STRING) + private Role role; + + @Column(name = "social_id", unique = true) + private String socialId; + + @Enumerated(EnumType.STRING) + @Column(name = "social_type") + private SocialType socialType; + + @Column(nullable = true) + private String profileImage; + + @Column(length = 500) + private String refreshToken; + + public void updateNickname(String nickName){ + this.nickName = nickName; + } + + public void updatePhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public void updateEmail(String email) { + this.email = email; + } + + public void updateProfileImage(String profileImage) { + this.profileImage = profileImage; + } + + public void updateRefreshToken(String refreshToken){this.refreshToken = refreshToken;} } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/enums/Grade.java b/src/main/java/com/eatsfine/eatsfine/domain/user/enums/Grade.java new file mode 100644 index 00000000..6f0b61c3 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/enums/Grade.java @@ -0,0 +1,4 @@ +package com.eatsfine.eatsfine.domain.user.enums; + +public enum Grade { +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/enums/Role.java b/src/main/java/com/eatsfine/eatsfine/domain/user/enums/Role.java new file mode 100644 index 00000000..857808c1 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/enums/Role.java @@ -0,0 +1,5 @@ +package com.eatsfine.eatsfine.domain.user.enums; + +public enum Role { + ROLE_CUSTOMER, ROLE_OWNER +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/enums/SocialType.java b/src/main/java/com/eatsfine/eatsfine/domain/user/enums/SocialType.java new file mode 100644 index 00000000..a0ef11b6 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/enums/SocialType.java @@ -0,0 +1,7 @@ +package com.eatsfine.eatsfine.domain.user.enums; + +public enum SocialType { + NAVER, + KAKAO, + GOOGLE +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/exception/UserException.java b/src/main/java/com/eatsfine/eatsfine/domain/user/exception/UserException.java new file mode 100644 index 00000000..114cce1f --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/exception/UserException.java @@ -0,0 +1,10 @@ +package com.eatsfine.eatsfine.domain.user.exception; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.exception.GeneralException; + +public class UserException extends GeneralException { + public UserException(BaseErrorCode code) { + super(code); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/repository/UserRepository.java b/src/main/java/com/eatsfine/eatsfine/domain/user/repository/UserRepository.java index 48f65767..12bca8a5 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/repository/UserRepository.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/repository/UserRepository.java @@ -1,7 +1,13 @@ package com.eatsfine.eatsfine.domain.user.repository; import com.eatsfine.eatsfine.domain.user.entity.User; +import com.eatsfine.eatsfine.domain.user.enums.SocialType; import org.springframework.data.jpa.repository.JpaRepository; -public interface UserRepository extends JpaRepository { +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + Optional findBySocialTypeAndSocialId(SocialType socialType, String socialId); + boolean existsByEmail(String email); } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserService.java b/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserService.java index 6d3ef6b6..1818c909 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserService.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserService.java @@ -1,4 +1,24 @@ package com.eatsfine.eatsfine.domain.user.service; +import com.eatsfine.eatsfine.domain.user.dto.request.UserRequestDto; +import com.eatsfine.eatsfine.domain.user.dto.response.UserResponseDto; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; + public interface UserService { + UserResponseDto.JoinResultDto signup(UserRequestDto.JoinDto joinDto); + + UserResponseDto.LoginResponseDto login(UserRequestDto.LoginDto loginDto); + + @Transactional(readOnly = true) + UserResponseDto.UserInfoDto getMemberInfo(HttpServletRequest request); + + @Transactional + String updateMemberInfo(UserRequestDto.UpdateDto updateDto, MultipartFile profileImage, HttpServletRequest request); + + void withdraw(HttpServletRequest request); + + void logout(HttpServletRequest request); + } diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserServiceImpl.java index d4457ffb..49706461 100644 --- a/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserServiceImpl.java +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/service/UserServiceImpl.java @@ -1,4 +1,221 @@ package com.eatsfine.eatsfine.domain.user.service; -public class UserServiceImpl { -} + +import com.eatsfine.eatsfine.domain.image.exception.ImageException; +import com.eatsfine.eatsfine.domain.image.status.ImageErrorStatus; +import com.eatsfine.eatsfine.domain.term.repository.TermRepository; +import com.eatsfine.eatsfine.domain.user.converter.UserConverter; +import com.eatsfine.eatsfine.domain.user.dto.request.UserRequestDto; +import com.eatsfine.eatsfine.domain.user.dto.response.UserResponseDto; +import com.eatsfine.eatsfine.domain.user.entity.User; +import com.eatsfine.eatsfine.domain.user.exception.UserException; +import com.eatsfine.eatsfine.domain.user.repository.UserRepository; +import com.eatsfine.eatsfine.domain.user.status.UserErrorStatus; +import com.eatsfine.eatsfine.global.config.jwt.JwtTokenProvider; +import com.eatsfine.eatsfine.global.s3.S3Service; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; +import org.springframework.web.multipart.MultipartFile; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserServiceImpl implements UserService{ + private final UserRepository userRepository; + private final TermRepository termRepository; + private final PasswordEncoder passwordEncoder; + private final JwtTokenProvider jwtTokenProvider; + private final S3Service s3Service; + + @Override + @Transactional + public UserResponseDto.JoinResultDto signup(UserRequestDto.JoinDto joinDto) { + // 1) 이메일 중복 체크 + if (userRepository.existsByEmail(joinDto.getEmail())) { + throw new UserException(UserErrorStatus.EMAIL_ALREADY_EXISTS); + } + + // 2) 비밀번호 인코딩 후 유저 생성 + String encoded = passwordEncoder.encode(joinDto.getPassword()); + User user = UserConverter.toUser(joinDto, encoded); + User savedUser = userRepository.save(user); + + // 3) 약관 동의 내역 저장 + termRepository.save(UserConverter.toUserTerm(joinDto, savedUser)); + + // 4) 응답 반환 + return UserConverter.toJoinResult(savedUser); + } + + @Override + @Transactional + public UserResponseDto.LoginResponseDto login(UserRequestDto.LoginDto loginDto) { + // 1) 사용자 조회 + User user = userRepository.findByEmail(loginDto.getEmail()) + .orElseThrow(() -> new UserException(UserErrorStatus.MEMBER_NOT_FOUND)); + + // 2) 비밀번호 검증 + if (!passwordEncoder.matches(loginDto.getPassword(), user.getPassword())) { + throw new UserException(UserErrorStatus.INVALID_PASSWORD); + } + + // 3) 토큰 발급 + String accessToken = jwtTokenProvider.createAccessToken(user.getEmail()); + String refreshToken = jwtTokenProvider.createRefreshToken(user.getEmail()); + + // 4) refreshToken 저장 + user.updateRefreshToken(refreshToken); + + return UserResponseDto.LoginResponseDto.builder() + .id(user.getId()) + .accessToken(accessToken) + .refreshToken(refreshToken) + .build(); + } + + @Override + @Transactional + public UserResponseDto.UserInfoDto getMemberInfo(HttpServletRequest request) { + User user = getCurrentUser(request); + return UserConverter.toUserInfo(user); + } + + @Override + @Transactional + public String updateMemberInfo(UserRequestDto.UpdateDto updateDto, + MultipartFile profileImage, + HttpServletRequest request) { + + User user = getCurrentUser(request); + + boolean changed = false; + + //닉네임/전화번호 부분 수정 + if (updateDto.getNickName() != null && !updateDto.getNickName().isBlank()) { + user.updateNickname(updateDto.getNickName()); + changed = true; + } + if (updateDto.getPhoneNumber() != null && !updateDto.getPhoneNumber().isBlank()) { + user.updatePhoneNumber(updateDto.getPhoneNumber()); + changed = true; + } + + //프로필 이미지 부분 수정 (파일이 들어온 경우에만) + if (profileImage != null && !profileImage.isEmpty()) { + validateProfileImage(profileImage); + + String oldKey = user.getProfileImage(); + String directory = "users/profile/" + user.getId(); + + // S3에 먼저 업로드 + String newKey = s3Service.upload(profileImage, directory); + + user.updateProfileImage(newKey); + changed = true; + + // 트랜잭션 롤백 시 방금 올린 새 파일 삭제 (S3 고아 파일 방지) + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status == STATUS_ROLLED_BACK) { + try { + s3Service.deleteByKey(newKey); + log.info("트랜잭션 롤백으로 인해 업로드된 새 이미지를 삭제했습니다. key={}", newKey); + } catch (Exception e) { + log.error("롤백 후 새 이미지 삭제 실패. key={}", newKey, e); + } + } + } + }); + + // 트랜잭션 커밋 성공 시 기존(옛날) 파일 삭제 + if (oldKey != null && !oldKey.isBlank()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + try { + s3Service.deleteByKey(oldKey); + log.info("프로필 수정 완료 후 이전 이미지를 삭제했습니다. oldKey={}", oldKey); + } catch (Exception e) { + log.warn("이전 프로필 이미지를 삭제하는 데 실패했습니다. oldKey={}", oldKey, e); + } + } + }); + } + } + + if (!changed) { + log.info("[Service] No changes detected. userId={}", user.getId()); + return "변경된 내용이 없습니다."; + } + + userRepository.save(user); + userRepository.flush(); + + log.info("[Service] Updated userId={}, nickname={}, phone={}, profileKey={}", + user.getId(), + user.getNickName(), + user.getPhoneNumber(), + user.getProfileImage()); + + return "회원 정보가 수정되었습니다."; + } + + private void validateProfileImage(MultipartFile file) { + String contentType = file.getContentType(); + if (contentType == null || !contentType.startsWith("image/")) { + throw new ImageException(ImageErrorStatus.INVALID_FILE_TYPE); + } + + // 용량 제한 (5MB) + long maxBytes = 5L * 1024 * 1024; + if (file.getSize() > maxBytes) { + throw new ImageException(ImageErrorStatus.FILE_TOO_LARGE); + } + } + + + @Override + @Transactional + public void withdraw(HttpServletRequest request) { + User user = getCurrentUser(request); + + String profileImage = user.getProfileImage(); + if (profileImage != null && !profileImage.isBlank()) { + try { + s3Service.deleteByKey(profileImage); + } catch (Exception e) { + log.warn("프로필 이미지 삭제 실패. key={}", profileImage, e); + } + } + + user.updateRefreshToken(null); + userRepository.delete(user); + } + + @Override + @Transactional + public void logout(HttpServletRequest request) { + User user = getCurrentUser(request); + + user.updateRefreshToken(null); + } + + private User getCurrentUser(HttpServletRequest request) { + String token = JwtTokenProvider.resolveToken(request); + if (token == null || token.isBlank() || !jwtTokenProvider.validateToken(token)) { + throw new UserException(UserErrorStatus.INVALID_TOKEN); + } + + String email = jwtTokenProvider.getEmailFromToken(token); + + return userRepository.findByEmail(email) + .orElseThrow(() -> new UserException(UserErrorStatus.MEMBER_NOT_FOUND)); + } +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/domain/user/status/UserErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/domain/user/status/UserErrorStatus.java new file mode 100644 index 00000000..c6a19078 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/domain/user/status/UserErrorStatus.java @@ -0,0 +1,51 @@ +package com.eatsfine.eatsfine.domain.user.status; + +import com.eatsfine.eatsfine.global.apiPayload.code.BaseErrorCode; +import com.eatsfine.eatsfine.global.apiPayload.code.ErrorReasonDto; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum UserErrorStatus implements BaseErrorCode { + + // 멤버 관련 에러 + MEMBER_NOT_FOUND(HttpStatus.BAD_REQUEST, "MEMBER4001", "사용자가 없습니다."), + NICKNAME_NOT_EXIST(HttpStatus.BAD_REQUEST, "MEMBER4002", "닉네임은 필수 입니다."), + EMAIL_ALREADY_EXISTS(HttpStatus.CONFLICT, "MEMBER4003", "이미 존재하는 이메일입니다."), + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "MEMBER4004", "비밀번호가 올바르지 않습니다."), + + // 토큰 관련 에러 + INVALID_TOKEN(HttpStatus.NOT_FOUND, "TOKEN4001", "토큰이 없습니다."), + EXPIRED_TOKEN(HttpStatus.UNAUTHORIZED, "TOKEN4002", "토큰이 만료되었습니다."), + REFRESH_TOKEN_NOT_ISSUED(HttpStatus.INTERNAL_SERVER_ERROR, "TOKEN5001", "리프레시 토큰이 발급되지 않았습니다."); + + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public ErrorReasonDto getReason() { + return ErrorReasonDto.builder() + .isSuccess(false) + .message(message) + .code(code) + .build(); + } + + @Override + public ErrorReasonDto getReasonHttpStatus() { + return ErrorReasonDto.builder() + .httpStatus(httpStatus) + .isSuccess(false) + .code(code) + .message(message) + .build(); + } + + + + + } diff --git a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java index 2d63881e..86129966 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java +++ b/src/main/java/com/eatsfine/eatsfine/global/apiPayload/code/status/ErrorStatus.java @@ -22,7 +22,7 @@ public enum ErrorStatus implements BaseErrorCode { @Override public ErrorReasonDto getReason() { return ErrorReasonDto.builder() - .isSuccess(true) + .isSuccess(false) .message(message) .code(code) .build(); @@ -32,7 +32,7 @@ public ErrorReasonDto getReason() { public ErrorReasonDto getReasonHttpStatus() { return ErrorReasonDto.builder() .httpStatus(httpStatus) - .isSuccess(true) + .isSuccess(false) .code(code) .message(message) .build(); diff --git a/src/main/java/com/eatsfine/eatsfine/global/auth/AuthCookieProvider.java b/src/main/java/com/eatsfine/eatsfine/global/auth/AuthCookieProvider.java new file mode 100644 index 00000000..2807b4a1 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/auth/AuthCookieProvider.java @@ -0,0 +1,33 @@ +package com.eatsfine.eatsfine.global.auth; + +import org.springframework.http.ResponseCookie; +import org.springframework.stereotype.Component; + +import java.time.Duration; + +@Component +public class AuthCookieProvider { + public ResponseCookie refreshTokenCookie(String refreshToken) { + if (refreshToken == null || refreshToken.isBlank()) { + throw new IllegalArgumentException("refreshToken must not be blank"); + } + + return ResponseCookie.from("refreshToken", refreshToken) + .httpOnly(true) + .secure(true) + .sameSite("Lax") + .path("/") + .maxAge(Duration.ofDays(14)) + .build(); + } + + public ResponseCookie clearRefreshTokenCookie() { + return ResponseCookie.from("refreshToken", "") + .httpOnly(true) + .secure(true) + .sameSite("Lax") + .path("/") + .maxAge(0) // 수명을 0으로 설정하여 즉시 삭제 + .build(); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/auth/CustomAccessDeniedHandler.java b/src/main/java/com/eatsfine/eatsfine/global/auth/CustomAccessDeniedHandler.java new file mode 100644 index 00000000..8c4ea21b --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/auth/CustomAccessDeniedHandler.java @@ -0,0 +1,26 @@ +package com.eatsfine.eatsfine.global.auth; + + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.web.access.AccessDeniedHandler; + +import java.io.IOException; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) + throws IOException, ServletException { + + response.setStatus(HttpServletResponse.SC_FORBIDDEN); // 403 + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"message\": \"접근 권한이 없습니다.\"}"); + } +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/global/auth/CustomAuthenticationEntryPoint.java b/src/main/java/com/eatsfine/eatsfine/global/auth/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..b9ee691d --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/auth/CustomAuthenticationEntryPoint.java @@ -0,0 +1,18 @@ +package com.eatsfine.eatsfine.global.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + @Override + public void commence(HttpServletRequest request, HttpServletResponse response, + AuthenticationException authException) throws IOException { + response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "인증이 필요합니다."); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/auth/UserDetailsServiceImpl.java b/src/main/java/com/eatsfine/eatsfine/global/auth/UserDetailsServiceImpl.java new file mode 100644 index 00000000..c8da9390 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/auth/UserDetailsServiceImpl.java @@ -0,0 +1,34 @@ +package com.eatsfine.eatsfine.global.auth; + + + + +import com.eatsfine.eatsfine.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.userdetails.User; +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; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UserDetailsServiceImpl implements UserDetailsService { + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + com.eatsfine.eatsfine.domain.user.entity.User user = userRepository.findByEmail(email) + .orElseThrow(() -> new UsernameNotFoundException("해당 사용자를 찾을 수 없습니다: " + email)); + + String password = user.getPassword(); + if (password == null) { + throw new UsernameNotFoundException("비밀번호 기반 로그인 대상이 아닙니다."); + } + + return new User(user.getEmail(), password, List.of()); + } +} + diff --git a/src/main/java/com/eatsfine/eatsfine/global/entity/BaseEntity.java b/src/main/java/com/eatsfine/eatsfine/global/common/BaseEntity.java similarity index 93% rename from src/main/java/com/eatsfine/eatsfine/global/entity/BaseEntity.java rename to src/main/java/com/eatsfine/eatsfine/global/common/BaseEntity.java index a9d9405c..5ecf6ad1 100644 --- a/src/main/java/com/eatsfine/eatsfine/global/entity/BaseEntity.java +++ b/src/main/java/com/eatsfine/eatsfine/global/common/BaseEntity.java @@ -1,4 +1,4 @@ -package com.eatsfine.eatsfine.global.entity; +package com.eatsfine.eatsfine.global.common; import jakarta.persistence.Column; import jakarta.persistence.EntityListeners; diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/SecurityConfig.java b/src/main/java/com/eatsfine/eatsfine/global/config/SecurityConfig.java new file mode 100644 index 00000000..f55d5cc4 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/SecurityConfig.java @@ -0,0 +1,91 @@ +package com.eatsfine.eatsfine.global.config; + +import com.eatsfine.eatsfine.global.auth.CustomAccessDeniedHandler; +import com.eatsfine.eatsfine.global.auth.CustomAuthenticationEntryPoint; +import com.eatsfine.eatsfine.global.config.jwt.JwtAuthenticationFilter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpMethod; +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.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.time.Duration; +import java.util.List; + +import static org.springframework.security.config.Customizer.withDefaults; + +@Configuration +@EnableWebSecurity +@RequiredArgsConstructor +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + private final CustomAuthenticationEntryPoint authenticationEntryPoint; + private final CustomAccessDeniedHandler accessDeniedHandler; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + + http + .cors(cors -> cors.configurationSource(corsConfigurationSource())) + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .exceptionHandling(exceptions -> exceptions + .authenticationEntryPoint(authenticationEntryPoint) + .accessDeniedHandler(accessDeniedHandler) + ) + .authorizeHttpRequests(auth -> auth + // preflight은 항상 허용 + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() + + // 공개 리소스 / 인증 없이 + .requestMatchers( + "/api/auth/**", + "/swagger-ui.html", + "/swagger-ui/**", + "/v3/api-docs/**", + "/api/v1/deploy/health-check", + "/swagger-resources/**" + ).permitAll() + + .requestMatchers("/auth/**", "/login", "/signup").permitAll() + + // 그 외는 인증 필요 + .anyRequest().authenticated() + ) + + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { // cors 설정 + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOriginPatterns(List.of("*")); // 운영 환경에서는 정확한 도메인만 명시 + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS", "PATCH")); + config.setAllowedHeaders(List.of("*")); + config.setExposedHeaders(List.of("Authorization", "Set-Cookie")); //쿠키, Authorization 헤더 노출 + config.setAllowCredentials(true); + config.setMaxAge(Duration.ofHours(1)); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } + + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/jwt/JwtAuthenticationFilter.java b/src/main/java/com/eatsfine/eatsfine/global/config/jwt/JwtAuthenticationFilter.java new file mode 100644 index 00000000..dffeb0dc --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/jwt/JwtAuthenticationFilter.java @@ -0,0 +1,64 @@ +package com.eatsfine.eatsfine.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.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtTokenProvider jwtTokenProvider; + private final UserDetailsService userDetailsService; + + @Override + protected void doFilterInternal(HttpServletRequest request, + HttpServletResponse response, + FilterChain chain) + throws ServletException, IOException { + + String uri = request.getRequestURI(); + System.out.println("요청 URI: " + uri); + + // 인증 없이 통과시킬 경로들 + if (uri.startsWith("/api/auth/login") || + uri.startsWith("/api/auth/signup") || + uri.startsWith("/oauth2") || + uri.startsWith("/login")) { + chain.doFilter(request, response); + return; + } + + + String token = JwtTokenProvider.resolveToken(request); + + if (token != null && jwtTokenProvider.validateToken(token)) { + try { + String email = jwtTokenProvider.getEmailFromToken(token); + UserDetails userDetails = userDetailsService.loadUserByUsername(email); + + UsernamePasswordAuthenticationToken authentication = + new UsernamePasswordAuthenticationToken( + userDetails, null, userDetails.getAuthorities()); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + SecurityContextHolder.getContext().setAuthentication(authentication); + } catch (Exception e) { + // 예외 로그 출력 + System.out.println("JWT 인증 오류: " + e.getMessage()); + } + } + + chain.doFilter(request, response); + } +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/jwt/JwtTokenProvider.java b/src/main/java/com/eatsfine/eatsfine/global/config/jwt/JwtTokenProvider.java new file mode 100644 index 00000000..f578c363 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/jwt/JwtTokenProvider.java @@ -0,0 +1,113 @@ +package com.eatsfine.eatsfine.global.config.jwt; + +import com.eatsfine.eatsfine.domain.user.exception.UserException; +import com.eatsfine.eatsfine.domain.user.status.UserErrorStatus; +import com.eatsfine.eatsfine.global.config.properties.Constants; +import org.springframework.security.core.userdetails.User; +import com.eatsfine.eatsfine.global.apiPayload.code.status.ErrorStatus; +import com.eatsfine.eatsfine.global.config.properties.JwtProperties; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Collections; +import java.util.Date; + +@Component +@RequiredArgsConstructor +public class JwtTokenProvider { + + private final JwtProperties jwtProperties; + + private Key getSigningKey() { + return Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8)); + } + + private final long accessTokenValidity = 1000L * 60 * 60; // 1시간 + private final long refreshTokenValidity = 1000L * 60 * 60 * 24 * 7; // 7일 + + public String createAccessToken(String email) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + accessTokenValidity); + return Jwts.builder() + .setSubject(email) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public String createRefreshToken(String email) { + Date now = new Date(); + Date expiry = new Date(now.getTime() + refreshTokenValidity); + return Jwts.builder() + .setSubject(email) + .setIssuedAt(now) + .setExpiration(expiry) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public boolean validateToken(String token) { + try { + Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token); // 유효성 검증 + return true; + } catch (JwtException | IllegalArgumentException e) { + return false; + } + } + + public Authentication getAuthentication(String token) { + Claims claims = Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + + String email = claims.getSubject(); + + User principal = new User(email, "", Collections.emptyList()); + return new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities()); + } + + 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; + } + + public Authentication extractAuthentication(HttpServletRequest request) { + String accessToken = resolveToken(request); + if (accessToken == null || !validateToken(accessToken)) { + throw new UserException(UserErrorStatus.INVALID_TOKEN); + } + return getAuthentication(accessToken); + } + + public String getEmailFromToken(String token) { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody() + .getSubject(); + + } + +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/properties/Constants.java b/src/main/java/com/eatsfine/eatsfine/global/config/properties/Constants.java new file mode 100644 index 00000000..d3e0d94f --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/properties/Constants.java @@ -0,0 +1,6 @@ +package com.eatsfine.eatsfine.global.config.properties; + +public final class Constants { + public static final String AUTH_HEADER = "Authorization"; + public static final String TOKEN_PREFIX = "Bearer "; +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/global/config/properties/JwtProperties.java b/src/main/java/com/eatsfine/eatsfine/global/config/properties/JwtProperties.java new file mode 100644 index 00000000..dd85c4fb --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/config/properties/JwtProperties.java @@ -0,0 +1,19 @@ +package com.eatsfine.eatsfine.global.config.properties; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +@Component +@Getter +@Setter +@Validated +@ConfigurationProperties("jwt") +public class JwtProperties { + @NotBlank + private String secret; + +} \ No newline at end of file diff --git a/src/main/java/com/eatsfine/eatsfine/global/validator/annotation/PasswordMatch.java b/src/main/java/com/eatsfine/eatsfine/global/validator/annotation/PasswordMatch.java new file mode 100644 index 00000000..d9f16cce --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/validator/annotation/PasswordMatch.java @@ -0,0 +1,21 @@ +package com.eatsfine.eatsfine.global.validator.annotation; + +import com.eatsfine.eatsfine.global.validator.valid.PasswordMatchValidator; +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +import java.lang.annotation.*; + +@Documented +@Constraint(validatedBy = PasswordMatchValidator.class) +@Target({ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface PasswordMatch { + String message() default "비밀번호와 비밀번호 확인이 일치하지 않습니다."; + Class[] groups() default {}; + Class[] payload() default {}; + + String passwordField() default "password"; + String confirmField() default "passwordConfirm"; + +} diff --git a/src/main/java/com/eatsfine/eatsfine/global/validator/valid/PasswordMatchValidator.java b/src/main/java/com/eatsfine/eatsfine/global/validator/valid/PasswordMatchValidator.java new file mode 100644 index 00000000..6fd130c6 --- /dev/null +++ b/src/main/java/com/eatsfine/eatsfine/global/validator/valid/PasswordMatchValidator.java @@ -0,0 +1,51 @@ +package com.eatsfine.eatsfine.global.validator.valid; + + +import com.eatsfine.eatsfine.global.validator.annotation.PasswordMatch; +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import org.springframework.beans.BeanWrapperImpl; + +public class PasswordMatchValidator implements ConstraintValidator { + + private String passwordFieldName; + private String confirmFieldName; + + @Override + public void initialize(PasswordMatch constraintAnnotation) { + // 어노테이션에서 정한 필드 이름을 가져옴 + this.passwordFieldName = constraintAnnotation.passwordField(); + this.confirmFieldName = constraintAnnotation.confirmField(); + } + + @Override + public boolean isValid(Object dto, ConstraintValidatorContext context) { + Object passwordValue = getFieldValue(dto, passwordFieldName); + Object confirmValue = getFieldValue(dto, confirmFieldName); + + // 둘 다 null이면 검증 패스 + if (passwordValue == null || confirmValue == null) { + return true; + } + + // 값 비교 + if (!passwordValue.equals(confirmValue)) { + context.disableDefaultConstraintViolation(); + context.buildConstraintViolationWithTemplate(context.getDefaultConstraintMessageTemplate()) + .addPropertyNode(confirmFieldName) + .addConstraintViolation(); + return false; + } + + return true; + } + + // 필드 값을 안전하게 가져오는 헬퍼 메서드 + private Object getFieldValue(Object object, String fieldName) { + try { + return new BeanWrapperImpl(object).getPropertyValue(fieldName); + } catch (Exception e) { + return null; + } + } +} \ No newline at end of file diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index a5a7c16b..fd69063b 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -33,4 +33,7 @@ cloud: region: ap-northeast-2 s3: bucket: eatsfine-images - base-url: https://eatsfine-images.s3.ap-northeast-2.amazonaws.com \ No newline at end of file + base-url: https://eatsfine-images.s3.ap-northeast-2.amazonaws.com + +jwt: + secret: ${SECRET_KEY}