diff --git a/.env.example b/.env.example index 383e1d1f..982d6870 100644 --- a/.env.example +++ b/.env.example @@ -30,7 +30,10 @@ COOKIE_SAME_SITE=Lax # CORS 허용 오리진. 콤마로 구분 # POST /api/v1/auth/refresh의 CSRF 방어에도 쓰인다. 이 목록에 없는 오리진의 재발급 요청은 403으로 차단된다 -CORS_ALLOWED_ORIGINS=http://localhost:3000 +# 프론트 로컬은 Next.js 3000, Vite 5173 +# 배포 환경에는 프론트 운영 주소와 Vercel 프리뷰 주소를 넣는다. 프리뷰는 브랜치마다 주소가 달라 개별 등록이 필요하다 +# 값을 고칠 때는 기존 목록을 덮어쓰지 말고 콤마로 덧붙여야 한다. 목록 전체를 교체하는 변수다 +CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:5173 # 인증번호 발송용 Gmail SMTP 계정 # MAIL_PASSWORD는 계정 비밀번호가 아니라 2단계 인증을 켠 뒤 발급한 16자리 앱 비밀번호다 diff --git a/src/main/java/com/slatto/domain/auth/controller/AuthController.java b/src/main/java/com/slatto/domain/auth/controller/AuthController.java index aa678898..79e70c5d 100644 --- a/src/main/java/com/slatto/domain/auth/controller/AuthController.java +++ b/src/main/java/com/slatto/domain/auth/controller/AuthController.java @@ -8,6 +8,7 @@ import com.slatto.domain.auth.dto.EmailVerificationConfirmResponse; import com.slatto.domain.auth.dto.EmailVerificationSendRequest; import com.slatto.domain.auth.dto.EmailVerificationSendResponse; +import com.slatto.domain.auth.dto.PasswordChangeRequest; import com.slatto.domain.auth.dto.PasswordResetRequest; import com.slatto.domain.auth.service.AuthService; import com.slatto.domain.auth.service.EmailVerificationService; @@ -23,8 +24,10 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -197,6 +200,32 @@ public ApiResponse confirmEmailVerificationCod return ApiResponse.success(CommonSuccessCode.OK, response); } + @Operation( + summary = "비밀번호 변경", + description = """ + 로그인한 상태에서 현재 비밀번호를 확인하고 새 비밀번호로 바꾼다. + + 비밀번호를 잊어버려 인증번호로 재설정하는 `POST /auth/password/reset` 과는 다른 경로다. + 변경 성공 시 리프레시 토큰을 새로 발급한다. + + 구글로만 가입해 비밀번호가 없는 계정은 이 API 로 설정할 수 없다. 비밀번호 찾기를 이용한다. + """ + ) + @PatchMapping("/password") + public ResponseEntity> changePassword( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody PasswordChangeRequest request + ) { + AuthService.EmailAuthResult result = authService.changePassword( + userId, request.currentPassword(), request.newPassword() + ); + + return ResponseEntity + .ok() + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie(result)) + .body(ApiResponse.success(CommonSuccessCode.OK, toEmailAuthResponse(result))); + } + @Operation( summary = "비밀번호 재설정", description = """ diff --git a/src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java b/src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java index 6689d47d..11ffb039 100644 --- a/src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java +++ b/src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java @@ -8,7 +8,10 @@ public record EmailSignupRequest( @NotBlank(message = "이름은 필수입니다.") - @Size(min = 1, max = 20, message = "이름은 1자 이상 20자 이하로 입력해야 합니다.") + @Pattern( + regexp = "^(?=.*\\S)[가-힣a-zA-Z0-9 ]{2,20}$", + message = "이름은 특수문자 없이 2자 이상 20자 이하로 입력해야 합니다." + ) String name, @NotBlank(message = "이메일은 필수입니다.") diff --git a/src/main/java/com/slatto/domain/auth/dto/PasswordChangeRequest.java b/src/main/java/com/slatto/domain/auth/dto/PasswordChangeRequest.java new file mode 100644 index 00000000..b8e5bdff --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/dto/PasswordChangeRequest.java @@ -0,0 +1,18 @@ +package com.slatto.domain.auth.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record PasswordChangeRequest( + + @NotBlank(message = "현재 비밀번호는 필수입니다.") + String currentPassword, + + @NotBlank(message = "새 비밀번호는 필수입니다.") + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{8,64}$", + message = "비밀번호는 영문·숫자·특수문자를 포함해 8자 이상 64자 이하로 입력해야 합니다." + ) + String newPassword +) { +} diff --git a/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java index 39f09b6f..9e651948 100644 --- a/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java @@ -20,7 +20,12 @@ public enum AuthErrorCode implements BaseCode { SIGNUP_SOCIAL_ACCOUNT_EXISTS(HttpStatus.CONFLICT, "AUTH_SIGNUP_SOCIAL409", "구글 계정으로 가입된 이메일입니다. 구글 로그인을 이용해 주세요."), // 이메일 미존재·비밀번호 불일치·소셜 전용 계정을 모두 같은 응답으로 처리한다. 이메일 열거 방지다. - LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_LOGIN401", "이메일 또는 비밀번호가 올바르지 않습니다."); + LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_LOGIN401", "이메일 또는 비밀번호가 올바르지 않습니다."), + + // 로그인한 본인이 호출하는 경로라 사유를 구분해도 새어 나갈 정보가 없다. + CURRENT_PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "AUTH_PASSWORD401", "현재 비밀번호가 올바르지 않습니다."), + PASSWORD_NOT_SET(HttpStatus.BAD_REQUEST, "AUTH_PASSWORD_NOT_SET400", "비밀번호가 설정되지 않은 계정입니다. 비밀번호 찾기를 이용해 주세요."), + PASSWORD_UNCHANGED(HttpStatus.BAD_REQUEST, "AUTH_PASSWORD_UNCHANGED400", "새 비밀번호가 기존 비밀번호와 같습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/slatto/domain/auth/service/AuthService.java b/src/main/java/com/slatto/domain/auth/service/AuthService.java index 71d49c90..968d15a0 100644 --- a/src/main/java/com/slatto/domain/auth/service/AuthService.java +++ b/src/main/java/com/slatto/domain/auth/service/AuthService.java @@ -17,6 +17,7 @@ import com.slatto.domain.user.repository.UserRepository; import com.slatto.global.config.properties.FrontendProperties; import com.slatto.global.exception.BaseException; +import com.slatto.global.response.code.CommonErrorCode; import com.slatto.global.security.JwtTokenProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -134,6 +135,31 @@ public EmailAuthResult login(String email, String rawPassword) { return toEmailAuthResult(user); } + // 재설정과 달리 리프레시 토큰을 새로 발급한다. 본인이 로그인한 상태에서 바꾸는 것이라 + // 세션을 끊을 이유가 없다. 유출을 가정하는 resetPassword 와 여기가 갈리는 지점이다. + @Transactional + public EmailAuthResult changePassword(Long userId, String currentPassword, String newRawPassword) { + Users user = userRepository.findByIdAndDeletedAtIsNull(userId) + .orElseThrow(() -> new BaseException(CommonErrorCode.NOT_FOUND)); + + if (!user.hasPassword()) { + throw new BaseException(AuthErrorCode.PASSWORD_NOT_SET); + } + + // 세션이 탈취된 상황에서 비밀번호까지 바꿀 수 있으면 계정을 통째로 빼앗긴다. + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { + throw new BaseException(AuthErrorCode.CURRENT_PASSWORD_MISMATCH); + } + + if (passwordEncoder.matches(newRawPassword, user.getPassword())) { + throw new BaseException(AuthErrorCode.PASSWORD_UNCHANGED); + } + + user.changePassword(passwordEncoder.encode(newRawPassword)); + + return toEmailAuthResult(user); + } + @Transactional public void resetPassword(String email, String newRawPassword) { emailVerificationService.consumeVerified(email, VerificationPurpose.PASSWORD_RESET); diff --git a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java index c87f2d3d..57446e90 100644 --- a/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java +++ b/src/main/java/com/slatto/domain/recruitment/controller/RecruitmentController.java @@ -39,7 +39,15 @@ public class RecruitmentController { private final RecruitmentService recruitmentService; - @Operation(summary = "구인구직 공고 작성") + @Operation( + summary = "구인구직 공고 작성", + description = """ + 필수는 `title`, `recruitPart`, `description`, `contact` 네 개다. + `category`, `lengthType`, `location`, `shootingPeriod`, `pay`, `deadline` 은 비워도 등록된다. + + `title` 은 5~50자다. `deadline` 을 비우면 수동으로 마감할 때까지 모집이 유지된다. + """ + ) @PostMapping @ResponseStatus(HttpStatus.CREATED) public ApiResponse createRecruitment( @@ -113,7 +121,19 @@ public ApiResponse getRecruitment( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "구인구직 공고 수정") + @Operation( + summary = "구인구직 공고 수정", + description = """ + 전달한 항목만 부분 수정된다. + + **마감된 공고는 내용을 수정할 수 없다.** 마감일이 지났거나 수동 마감한 공고에 + 내용 수정을 요청하면 `RECRUITMENT_CLOSED_EDIT400` 이 반환된다. + + 다만 `status` 를 `RECRUITING` 으로 보내는 요청은 통과한다. 상태 변경이 같은 API 라 + 전면 차단하면 마감을 되돌릴 방법이 없어지기 때문이다. 마감일이 지나 자동 마감된 + 공고를 되살리려면 `deadline` 도 함께 보내야 한다. + """ + ) @PatchMapping("/{recruitmentId}") public ApiResponse updateRecruitment( @AuthenticationPrincipal Long currentUserId, diff --git a/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentCreateRequest.java b/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentCreateRequest.java index 1d104e18..5e56e946 100644 --- a/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentCreateRequest.java +++ b/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentCreateRequest.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.FutureOrPresent; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.Getter; @@ -19,29 +20,27 @@ public class RecruitmentCreateRequest { @NotBlank(message = "공고 제목은 필수입니다.") - @Size(max = 100, message = "공고 제목은 최대 100자까지 입력할 수 있습니다.") + @Size(min = 5, max = 50, message = "공고 제목은 5자 이상 50자 이하로 입력해야 합니다.") private String title; - @NotNull(message = "카테고리는 필수입니다.") private CategoryName category; - @NotNull(message = "영상 길이 유형은 필수입니다.") private LengthType lengthType; @NotNull(message = "모집 파트는 필수입니다.") private RoleName recruitPart; - @NotNull(message = "지역은 필수입니다.") private RegionName location; - @NotBlank(message = "촬영 기간은 필수입니다.") + @Pattern(regexp = "(?s).*\\S.*", message = "촬영 기간은 공백일 수 없습니다.") @Size(max = 50, message = "촬영 기간은 최대 50자까지 입력할 수 있습니다.") private String shootingPeriod; - @NotBlank(message = "급여는 필수입니다.") + @Pattern(regexp = "(?s).*\\S.*", message = "급여는 공백일 수 없습니다.") @Size(max = 50, message = "급여는 최대 50자까지 입력할 수 있습니다.") private String pay; + // 지원자가 연락할 방법이 없으면 공고가 성립하지 않아 선택으로 열지 않는다. @NotBlank(message = "연락처는 필수입니다.") @Size(max = 100, message = "연락처는 최대 100자까지 입력할 수 있습니다.") private String contact; @@ -50,7 +49,7 @@ public class RecruitmentCreateRequest { @Size(max = 2000, message = "상세 내용은 최대 2000자까지 입력할 수 있습니다.") private String description; - @NotNull(message = "마감일은 필수입니다.") + // 마감일을 비우면 수동으로 마감할 때까지 모집이 유지된다. @FutureOrPresent(message = "마감일은 오늘 또는 그 이후여야 합니다.") private LocalDate deadline; } diff --git a/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentUpdateRequest.java b/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentUpdateRequest.java index ca0ec0fe..2d9adad0 100644 --- a/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentUpdateRequest.java +++ b/src/main/java/com/slatto/domain/recruitment/dto/RecruitmentUpdateRequest.java @@ -20,7 +20,7 @@ public class RecruitmentUpdateRequest { @Pattern(regexp = "(?s).*\\S.*", message = "공고 제목은 공백일 수 없습니다.") - @Size(max = 100, message = "공고 제목은 100자 이하여야 합니다.") + @Size(min = 5, max = 50, message = "공고 제목은 5자 이상 50자 이하여야 합니다.") private String title; private CategoryName category; diff --git a/src/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.java b/src/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.java index 692bc1b2..04395a01 100644 --- a/src/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.java +++ b/src/main/java/com/slatto/domain/recruitment/exception/RecruitmentErrorCode.java @@ -12,7 +12,8 @@ public enum RecruitmentErrorCode implements BaseCode { RECRUITMENT_CLOSED(HttpStatus.BAD_REQUEST, "RECRUITMENT_CLOSED400", "마감된 공고에는 지원할 수 없습니다."), RECRUITMENT_SELF_APPLICATION(HttpStatus.BAD_REQUEST, "RECRUITMENT_SELF400", "본인이 작성한 공고에는 지원할 수 없습니다."), APPLICATION_ALREADY_APPLIED(HttpStatus.CONFLICT, "APPLICATION409", "이미 지원한 공고입니다."), - APPLICATION_ALREADY_HANDLED(HttpStatus.BAD_REQUEST, "APPLICATION_ALREADY_HANDLED400", "이미 수락 또는 거절 처리된 지원입니다."); + APPLICATION_ALREADY_HANDLED(HttpStatus.BAD_REQUEST, "APPLICATION_ALREADY_HANDLED400", "이미 수락 또는 거절 처리된 지원입니다."), + RECRUITMENT_CLOSED_NOT_EDITABLE(HttpStatus.BAD_REQUEST, "RECRUITMENT_CLOSED_EDIT400", "마감된 공고는 수정할 수 없습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/slatto/domain/recruitment/repository/RecruitmentRepository.java b/src/main/java/com/slatto/domain/recruitment/repository/RecruitmentRepository.java index 163517f6..d0b05f9d 100644 --- a/src/main/java/com/slatto/domain/recruitment/repository/RecruitmentRepository.java +++ b/src/main/java/com/slatto/domain/recruitment/repository/RecruitmentRepository.java @@ -12,11 +12,24 @@ import org.springframework.data.repository.query.Param; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; public interface RecruitmentRepository extends JpaRepository { + // 작성자가 탈퇴하면 공고도 함께 내린다. 연락받을 사람이 없는 공고가 목록에 남으면 + // 지원자가 응답 없는 공고에 지원하게 된다. + // clearAutomatically 는 쓰지 않는다. 같은 트랜잭션에 로딩된 엔티티가 detach 된다. + @Modifying + @Query(""" + update Recruitment r + set r.deletedAt = :deletedAt + where r.writer.id = :writerId + and r.deletedAt is null + """) + int softDeleteAllByWriterId(@Param("writerId") Long writerId, @Param("deletedAt") LocalDateTime deletedAt); + Optional findByIdAndDeletedAtIsNull(Long id); boolean existsByIdAndDeletedAtIsNull(Long id); diff --git a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java index a820de32..e86956e3 100644 --- a/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java +++ b/src/main/java/com/slatto/domain/recruitment/service/RecruitmentService.java @@ -14,6 +14,7 @@ import com.slatto.domain.recruitment.enums.RecruitmentApplicationStatus; import com.slatto.domain.recruitment.enums.RecruitmentSortType; import com.slatto.domain.recruitment.enums.RecruitmentStatus; +import com.slatto.domain.recruitment.exception.RecruitmentErrorCode; import com.slatto.domain.recruitment.repository.RecruitmentApplicationRepository; import com.slatto.domain.recruitment.repository.RecruitmentBookmarkRepository; import com.slatto.domain.recruitment.repository.RecruitmentRepository; @@ -107,6 +108,12 @@ public RecruitmentDetailResponse updateRecruitment( validateWriter(recruitment, currentUserId); + // 마감된 공고는 내용을 수정할 수 없다. 다만 다시 모집중으로 되돌리는 요청은 통과시킨다. + // 전면 차단하면 상태 변경도 같은 API 를 쓰므로 수동 마감을 취소할 방법이 사라진다. + if (isClosed(recruitment) && !isReopening(recruitment, request)) { + throw new BaseException(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE); + } + recruitment.update( request.getTitle(), request.getCategory(), @@ -399,6 +406,29 @@ private RoleName getPrimaryRole(Long userId) { return roles.isEmpty() ? null : roles.get(0).getRoleName(); } + // status 만 RECRUITING 으로 바꿔도 마감일이 과거면 여전히 마감이다. 그 상태로 통과시키면 + // 같은 요청에 실린 내용 변경까지 반영돼 마감 공고 수정 금지가 우회된다. + // 적용 후 실제로 모집중이 되는 요청만 재개로 인정한다. + private boolean isReopening(Recruitment recruitment, RecruitmentUpdateRequest request) { + if (request.getStatus() != RecruitmentStatus.RECRUITING) { + return false; + } + + LocalDate appliedDeadline = request.getDeadline() != null + ? request.getDeadline() + : recruitment.getDeadline(); + + return appliedDeadline == null || !appliedDeadline.isBefore(recruitmentConverter.currentDate()); + } + + private boolean isClosed(Recruitment recruitment) { + return recruitmentConverter.resolveStatus( + recruitment.getClosedManually(), + recruitment.getDeadline(), + recruitmentConverter.currentDate() + ) == RecruitmentStatus.CLOSED; + } + private List getUserRegions(Long userId) { return locationRepository.findAllByUserIdAndRecruitmentIsNullOrderByIdAsc(userId) .stream() diff --git a/src/main/java/com/slatto/domain/user/controller/UserController.java b/src/main/java/com/slatto/domain/user/controller/UserController.java index c53d6631..27db6fc6 100644 --- a/src/main/java/com/slatto/domain/user/controller/UserController.java +++ b/src/main/java/com/slatto/domain/user/controller/UserController.java @@ -7,6 +7,8 @@ import com.slatto.domain.user.dto.UserProfileUpdateResponse; import com.slatto.domain.user.dto.UserProfileImageResponse; import com.slatto.domain.user.dto.UserPublicProfileResponse; +import com.slatto.domain.user.dto.UserWithdrawRequest; +import com.slatto.domain.auth.support.AuthCookieFactory; import com.slatto.domain.user.service.UserService; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; @@ -14,7 +16,10 @@ import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -34,6 +39,7 @@ public class UserController { private final UserService userService; + private final AuthCookieFactory authCookieFactory; @Operation(summary = "유저 정보 조회", description = "로그인한 유저의 기본 정보와 온보딩 완료 여부를 조회한다.") @GetMapping("/me") @@ -43,7 +49,15 @@ public ApiResponse getMyInfo(@AuthenticationPrincipal Long userI return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "온보딩 정보 저장", description = "온보딩에서 입력한 약관 동의·역할·지역·카테고리·프로필 정보를 저장한다. 최초 1회만 허용한다.") + @Operation( + summary = "온보딩 정보 저장", + description = """ + 온보딩에서 입력한 약관 동의·역할·지역·카테고리·프로필 정보를 저장한다. 최초 1회만 허용한다. + + `roles`, `regions`, `categories` 는 각각 1개 이상이어야 한다. 중복 값은 저장 시 제거된다. + `nickname` 은 특수문자 없이 2~20자, `bio` 는 200자 이하다. + """ + ) @PostMapping("/onboarding") public ApiResponse completeOnboarding( @AuthenticationPrincipal Long userId, @@ -54,7 +68,15 @@ public ApiResponse completeOnboarding( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로필 수정", description = "마이페이지에서 내 프로필 정보를 수정한다. 전달된 항목만 부분 수정된다.") + @Operation( + summary = "프로필 수정", + description = """ + 마이페이지에서 내 프로필 정보를 수정한다. 전달된 항목만 부분 수정된다. + + `nickname` 은 특수문자 없이 2~20자, `bio` 는 200자 이하다. + `locations` 를 전달하면 기존 활동 지역을 모두 지우고 전달한 값으로 교체한다. + """ + ) @PatchMapping("/me") public ApiResponse updateProfile( @AuthenticationPrincipal Long userId, @@ -65,7 +87,14 @@ public ApiResponse updateProfile( return ApiResponse.success(CommonSuccessCode.OK, response); } - @Operation(summary = "프로필 이미지 업로드", description = "프로필 이미지를 S3에 업로드하고 CDN 공개 URL로 교체한다.") + @Operation( + summary = "프로필 이미지 업로드", + description = """ + 프로필 이미지를 S3에 업로드하고 CDN 공개 URL로 교체한다. + + **최대 2MB**, jpg·jpeg·png·webp 만 허용한다. MIME 타입과 확장자가 모두 일치해야 한다. + """ + ) @PutMapping(value = "/me/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ApiResponse uploadProfileImage( @AuthenticationPrincipal Long userId, @@ -76,6 +105,34 @@ public ApiResponse uploadProfileImage( return ApiResponse.success(CommonSuccessCode.OK, response); } + @Operation( + summary = "회원 탈퇴", + description = """ + 계정을 탈퇴 처리한다. 물리 삭제가 아니라 `deleted_at` 기록과 개인정보 익명화로 처리한다. + + 프로젝트·공고 등 연관 데이터가 유저를 참조하고 있어 행 자체는 남기며, + 이메일·닉네임·소셜 ID·프로필 이미지·소개는 지운다. + + 포트폴리오와 작성한 공고가 함께 내려가고 리프레시 토큰은 삭제된다. + 이메일이 비워지므로 같은 이메일로 다시 가입하면 새 계정으로 시작한다. + + **비밀번호가 설정된 계정은 `password` 로 재인증한다.** 틀리면 `USER_WITHDRAW_PASSWORD401` 이다. + 구글로만 가입해 비밀번호가 없는 계정은 `password` 를 보내지 않아도 된다. + """ + ) + @DeleteMapping("/me") + public ResponseEntity> withdraw( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody UserWithdrawRequest request + ) { + userService.withdraw(userId, request); + + return ResponseEntity + .ok() + .header(HttpHeaders.SET_COOKIE, authCookieFactory.expiredRefreshToken().toString()) + .body(ApiResponse.success(CommonSuccessCode.OK, null)); + } + @Operation(summary = "공개 프로필 조회", description = "다른 유저의 공개 프로필을 조회한다. 이메일 등 비공개 필드는 제외된다.") @GetMapping("/{userId}") public ApiResponse getPublicProfile(@PathVariable Long userId) { diff --git a/src/main/java/com/slatto/domain/user/dto/UserOnboardingRequest.java b/src/main/java/com/slatto/domain/user/dto/UserOnboardingRequest.java index b6cc9516..966488c2 100644 --- a/src/main/java/com/slatto/domain/user/dto/UserOnboardingRequest.java +++ b/src/main/java/com/slatto/domain/user/dto/UserOnboardingRequest.java @@ -7,6 +7,7 @@ import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotEmpty; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.Getter; @@ -23,7 +24,10 @@ public class UserOnboardingRequest { private Boolean agreedTerms; @NotBlank(message = "닉네임은 필수입니다.") - @Size(min = 1, max = 20, message = "닉네임은 1자 이상 20자 이하로 입력해야 합니다.") + @Pattern( + regexp = "^(?=.*\\S)[가-힣a-zA-Z0-9 ]{2,20}$", + message = "닉네임은 특수문자 없이 2자 이상 20자 이하로 입력해야 합니다." + ) private String nickname; @NotEmpty(message = "활동 역할은 1개 이상 선택해야 합니다.") @@ -35,6 +39,7 @@ public class UserOnboardingRequest { @NotEmpty(message = "관심 카테고리는 1개 이상 선택해야 합니다.") private List<@NotNull(message = "관심 카테고리는 필수입니다.") CategoryName> categories; + @Size(max = 200, message = "소개는 200자 이하로 입력해야 합니다.") private String bio; @Size(max = 500, message = "프로필 이미지 URL은 500자 이하로 입력해야 합니다.") diff --git a/src/main/java/com/slatto/domain/user/dto/UserProfileUpdateRequest.java b/src/main/java/com/slatto/domain/user/dto/UserProfileUpdateRequest.java index ea1d1260..8aabadd6 100644 --- a/src/main/java/com/slatto/domain/user/dto/UserProfileUpdateRequest.java +++ b/src/main/java/com/slatto/domain/user/dto/UserProfileUpdateRequest.java @@ -4,6 +4,7 @@ import com.slatto.domain.user.enums.RegionName; import com.slatto.domain.user.enums.RoleName; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.Getter; @@ -15,12 +16,16 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class UserProfileUpdateRequest { - @Size(min = 1, max = 20, message = "닉네임은 1자 이상 20자 이하로 입력해야 합니다.") + @Pattern( + regexp = "^(?=.*\\S)[가-힣a-zA-Z0-9 ]{2,20}$", + message = "닉네임은 특수문자 없이 2자 이상 20자 이하로 입력해야 합니다." + ) private String nickname; @Size(max = 500, message = "프로필 이미지 URL은 500자 이하로 입력해야 합니다.") private String profileImageUrl; + @Size(max = 200, message = "소개는 200자 이하로 입력해야 합니다.") private String bio; @Size(min = 1, message = "활동 지역은 1개 이상 선택해야 합니다.") diff --git a/src/main/java/com/slatto/domain/user/dto/UserWithdrawRequest.java b/src/main/java/com/slatto/domain/user/dto/UserWithdrawRequest.java new file mode 100644 index 00000000..5bb84486 --- /dev/null +++ b/src/main/java/com/slatto/domain/user/dto/UserWithdrawRequest.java @@ -0,0 +1,20 @@ +package com.slatto.domain.user.dto; + +import jakarta.validation.constraints.AssertTrue; +import jakarta.validation.constraints.NotNull; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class UserWithdrawRequest { + + @NotNull(message = "탈퇴 동의 여부는 필수입니다.") + @AssertTrue(message = "탈퇴에 동의해야 합니다.") + private Boolean agreed; + + // 비밀번호가 설정된 계정만 재인증한다. 소셜로만 가입한 계정은 확인할 비밀번호가 없어 + // 필수로 걸면 탈퇴 자체가 막힌다. 소셜 재인증 방식은 미확정이라 이번 범위에 넣지 않는다. + private String password; +} diff --git a/src/main/java/com/slatto/domain/user/entity/Users.java b/src/main/java/com/slatto/domain/user/entity/Users.java index 3db81442..598842b1 100644 --- a/src/main/java/com/slatto/domain/user/entity/Users.java +++ b/src/main/java/com/slatto/domain/user/entity/Users.java @@ -104,6 +104,19 @@ public void updateProfile(String nickname, String bio, String profileImageUrl) { } } + // email 과 nickname 이 NOT NULL 이라 비울 수 없다. id 를 섞은 고유 값으로 덮어 + // 개인정보를 지우면서 email 유니크 제약도 피한다. 원래 주소가 사라지므로 재가입이 열린다. + // .invalid 는 RFC 2606 예약 TLD 라 실수로 발송돼도 외부로 나가지 않는다. + public void withdraw(LocalDateTime withdrawnAt) { + this.deletedAt = withdrawnAt; + this.email = "withdrawn_" + id + "@slatto.invalid"; + this.nickname = "탈퇴한 사용자_" + id; + this.password = null; + this.socialId = null; + this.profileImageUrl = null; + this.bio = null; + } + public void completeOnboarding(String nickname, String bio, String profileImageUrl) { this.nickname = nickname; this.bio = bio; diff --git a/src/main/java/com/slatto/domain/user/exception/UserErrorCode.java b/src/main/java/com/slatto/domain/user/exception/UserErrorCode.java index 6acb3cf6..5992f9d4 100644 --- a/src/main/java/com/slatto/domain/user/exception/UserErrorCode.java +++ b/src/main/java/com/slatto/domain/user/exception/UserErrorCode.java @@ -12,7 +12,8 @@ public enum UserErrorCode implements BaseCode { ONBOARDING_ALREADY_COMPLETED(HttpStatus.CONFLICT, "ONBOARDING409", "이미 온보딩을 완료한 유저입니다."), PROFILE_IMAGE_EMPTY(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_EMPTY400", "업로드할 프로필 이미지가 비어 있습니다."), PROFILE_IMAGE_INVALID_TYPE(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_INVALID_TYPE400", "지원하지 않는 프로필 이미지 형식입니다."), - PROFILE_IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_SIZE400", "프로필 이미지는 최대 10MB까지 업로드할 수 있습니다."); + PROFILE_IMAGE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "USER_PROFILE_IMAGE_SIZE400", "프로필 이미지는 최대 2MB까지 업로드할 수 있습니다."), + WITHDRAW_PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "USER_WITHDRAW_PASSWORD401", "비밀번호가 일치하지 않습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/slatto/domain/user/repository/UserPortfolioRepository.java b/src/main/java/com/slatto/domain/user/repository/UserPortfolioRepository.java index c5588273..ceb73625 100644 --- a/src/main/java/com/slatto/domain/user/repository/UserPortfolioRepository.java +++ b/src/main/java/com/slatto/domain/user/repository/UserPortfolioRepository.java @@ -3,9 +3,11 @@ import com.slatto.domain.user.entity.UserPortfolio; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -26,4 +28,15 @@ List findActivePortfoliosByCursor( @Param("cursor") Long cursor, Pageable pageable ); + + // 탈퇴 시 포트폴리오를 한 번에 내린다. 건별 로딩 없이 처리하려고 벌크 업데이트를 쓴다. + // clearAutomatically 는 쓰지 않는다. 같은 트랜잭션에 로딩된 Users 가 detach 된다. + @Modifying + @Query(""" + update UserPortfolio p + set p.deletedAt = :deletedAt + where p.user.id = :userId + and p.deletedAt is null + """) + int softDeleteAllByUserId(@Param("userId") Long userId, @Param("deletedAt") LocalDateTime deletedAt); } diff --git a/src/main/java/com/slatto/domain/user/service/UserService.java b/src/main/java/com/slatto/domain/user/service/UserService.java index 27b285c3..2c1b2677 100644 --- a/src/main/java/com/slatto/domain/user/service/UserService.java +++ b/src/main/java/com/slatto/domain/user/service/UserService.java @@ -7,6 +7,10 @@ import com.slatto.domain.user.dto.UserProfileUpdateResponse; import com.slatto.domain.user.dto.UserProfileImageResponse; import com.slatto.domain.user.dto.UserPublicProfileResponse; +import com.slatto.domain.user.dto.UserWithdrawRequest; +import com.slatto.domain.auth.repository.RefreshTokenRepository; +import com.slatto.domain.recruitment.repository.RecruitmentRepository; +import com.slatto.domain.user.repository.UserPortfolioRepository; import com.slatto.domain.user.entity.Location; import com.slatto.domain.user.entity.UserCategory; import com.slatto.domain.user.entity.UserRole; @@ -25,6 +29,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; @@ -32,6 +37,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; import java.util.List; import java.util.Locale; import java.util.Map; @@ -44,7 +50,7 @@ @Transactional(readOnly = true) public class UserService { - private static final long MAX_PROFILE_IMAGE_SIZE = 10L * 1024 * 1024; + private static final long MAX_PROFILE_IMAGE_SIZE = 2L * 1024 * 1024; private static final String PROFILE_IMAGE_STORAGE_KEY_FORMAT = "users/%d/profile-images/%s.%s"; private static final Map> ALLOWED_EXTENSIONS_BY_CONTENT_TYPE = Map.of( "image/jpeg", Set.of("jpg", "jpeg"), @@ -56,6 +62,10 @@ public class UserService { private final UserRoleRepository userRoleRepository; private final UserCategoryRepository userCategoryRepository; private final LocationRepository locationRepository; + private final UserPortfolioRepository userPortfolioRepository; + private final RecruitmentRepository recruitmentRepository; + private final RefreshTokenRepository refreshTokenRepository; + private final PasswordEncoder passwordEncoder; private final StorageService storageService; @Value("${cloud.aws.s3.public-base-url:}") @@ -265,6 +275,34 @@ public UserPublicProfileResponse getPublicProfile(Long userId) { .build(); } + // 유저 행은 남긴다. 프로젝트·공고 등 연관 데이터가 FK 로 참조하고 있어 지우면 이력이 끊긴다. + @Transactional + public void withdraw(Long userId, UserWithdrawRequest request) { + Users user = getUserOrThrow(userId); + + // 세션이 탈취된 상태에서 탈퇴까지 가능하면 계정을 통째로 지워버릴 수 있다. + // matches 는 raw 가 null 이면 IllegalArgumentException 을 던져 500 이 나간다. 먼저 걸러낸다. + if (user.hasPassword() + && (request.getPassword() == null + || !passwordEncoder.matches(request.getPassword(), user.getPassword()))) { + throw new BaseException(UserErrorCode.WITHDRAW_PASSWORD_MISMATCH); + } + + LocalDateTime withdrawnAt = LocalDateTime.now(); + + // withdraw 가 URL 을 지우기 전에 키를 뽑아둔다. URL 만 비우면 스토리지 객체가 남아 + // 기존 공개 URL 을 아는 사람은 탈퇴 후에도 프로필 사진을 계속 볼 수 있다. + String profileImageStorageKey = extractManagedStorageKey(user.getProfileImageUrl()); + + userPortfolioRepository.softDeleteAllByUserId(userId, withdrawnAt); + recruitmentRepository.softDeleteAllByWriterId(userId, withdrawnAt); + refreshTokenRepository.deleteByUser(user); + + user.withdraw(withdrawnAt); + + registerPreviousFileDeletionAfterCommit(profileImageStorageKey); + } + private List getUserRegions(Long userId) { return locationRepository.findAllByUserIdAndRecruitmentIsNullOrderByIdAsc(userId) .stream() diff --git a/src/main/java/com/slatto/global/security/JwtAuthenticationFilter.java b/src/main/java/com/slatto/global/security/JwtAuthenticationFilter.java index ad55b5bd..95939bb8 100644 --- a/src/main/java/com/slatto/global/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/slatto/global/security/JwtAuthenticationFilter.java @@ -1,5 +1,6 @@ package com.slatto.global.security; +import com.slatto.domain.user.repository.UserRepository; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -22,6 +23,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private static final String BEARER_PREFIX = "Bearer "; private final JwtTokenProvider jwtTokenProvider; + private final UserRepository userRepository; @Override protected void doFilterInternal( @@ -34,7 +36,10 @@ protected void doFilterInternal( if (token != null && SecurityContextHolder.getContext().getAuthentication() == null) { Long userId = jwtTokenProvider.parseUserId(token, false); - if (userId != null) { + // 탈퇴해도 이미 발급된 액세스 토큰은 만료까지 서명 검증을 통과한다. + // 여기서 걸러내지 않으면 탈퇴 후에도 그 시간 동안 API 가 열린다. + // 인증된 요청마다 PK 조회가 한 번 늘어난다. + if (userId != null && userRepository.existsByIdAndDeletedAtIsNull(userId)) { UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( userId, null, List.of() ); diff --git a/src/test/java/com/slatto/domain/auth/service/PasswordChangeTest.java b/src/test/java/com/slatto/domain/auth/service/PasswordChangeTest.java new file mode 100644 index 00000000..f275f140 --- /dev/null +++ b/src/test/java/com/slatto/domain/auth/service/PasswordChangeTest.java @@ -0,0 +1,144 @@ +package com.slatto.domain.auth.service; + +import com.slatto.domain.auth.client.GoogleOAuthClient; +import com.slatto.domain.auth.exception.AuthErrorCode; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.SocialType; +import com.slatto.domain.user.repository.UserRepository; +import com.slatto.global.config.properties.FrontendProperties; +import com.slatto.global.exception.BaseException; +import com.slatto.global.security.JwtTokenProvider; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; + +@DataJpaTest +@Import({AuthService.class, PasswordChangeTest.PasswordEncoderTestConfig.class}) +@TestPropertySource(properties = { + "spring.jpa.database=h2", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +class PasswordChangeTest { + + private static final String CURRENT_PASSWORD = "slatto!2026"; + private static final String NEW_PASSWORD = "slatto@2027"; + + @TestConfiguration + static class PasswordEncoderTestConfig { + + // 실제 BCrypt 를 쓴다. 목으로 대체하면 현재 비밀번호 검증이 통과하는지 확인할 수 없다. + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + } + + @MockitoBean + private GoogleOAuthClient googleOAuthClient; + + @MockitoBean + private EmailVerificationService emailVerificationService; + + @MockitoBean + private JwtTokenProvider jwtTokenProvider; + + @MockitoBean + private FrontendProperties frontendProperties; + + @Autowired + private AuthService authService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private EntityManager entityManager; + + private Long emailUserId; + private Long socialOnlyUserId; + + @BeforeEach + void setUp() { + given(jwtTokenProvider.createAccessToken(org.mockito.ArgumentMatchers.anyLong())) + .willReturn("access-token"); + given(jwtTokenProvider.createRefreshToken(org.mockito.ArgumentMatchers.anyLong())) + .willReturn("refresh-token"); + given(jwtTokenProvider.refreshTokenExpiresAt()) + .willReturn(LocalDateTime.now().plusDays(14)); + + Users emailUser = userRepository.save( + Users.createEmailUser("email@slatto.com", "이메일유저", passwordEncoder.encode(CURRENT_PASSWORD)) + ); + emailUserId = emailUser.getId(); + + Users socialOnlyUser = userRepository.save( + Users.createSocialUser("social@slatto.com", "소셜유저", null, SocialType.GOOGLE, "social-1") + ); + socialOnlyUserId = socialOnlyUser.getId(); + + entityManager.flush(); + entityManager.clear(); + } + + @Test + @DisplayName("현재 비밀번호가 맞으면 새 비밀번호로 바뀐다") + void changesPassword() { + authService.changePassword(emailUserId, CURRENT_PASSWORD, NEW_PASSWORD); + entityManager.flush(); + entityManager.clear(); + + String stored = userRepository.findById(emailUserId).orElseThrow().getPassword(); + + assertThat(passwordEncoder.matches(NEW_PASSWORD, stored)).isTrue(); + assertThat(passwordEncoder.matches(CURRENT_PASSWORD, stored)).isFalse(); + } + + // 세션이 탈취된 상태에서 비밀번호까지 바꿀 수 있으면 계정을 통째로 빼앗긴다. + @Test + @DisplayName("현재 비밀번호가 틀리면 거부한다") + void rejectsWrongCurrentPassword() { + assertThatThrownBy(() -> authService.changePassword(emailUserId, "wrong!2026", NEW_PASSWORD)) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(AuthErrorCode.CURRENT_PASSWORD_MISMATCH); + } + + @Test + @DisplayName("비밀번호가 없는 소셜 전용 계정은 거부한다") + void rejectsSocialOnlyAccount() { + assertThatThrownBy(() -> authService.changePassword(socialOnlyUserId, CURRENT_PASSWORD, NEW_PASSWORD)) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(AuthErrorCode.PASSWORD_NOT_SET); + } + + @Test + @DisplayName("기존과 같은 비밀번호는 거부한다") + void rejectsUnchangedPassword() { + assertThatThrownBy(() -> authService.changePassword(emailUserId, CURRENT_PASSWORD, CURRENT_PASSWORD)) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(AuthErrorCode.PASSWORD_UNCHANGED); + } + +} diff --git a/src/test/java/com/slatto/domain/recruitment/service/RecruitmentCloseEditTest.java b/src/test/java/com/slatto/domain/recruitment/service/RecruitmentCloseEditTest.java new file mode 100644 index 00000000..a0267001 --- /dev/null +++ b/src/test/java/com/slatto/domain/recruitment/service/RecruitmentCloseEditTest.java @@ -0,0 +1,124 @@ +package com.slatto.domain.recruitment.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.slatto.domain.project.enums.LengthType; +import com.slatto.domain.recruitment.converter.RecruitmentConverter; +import com.slatto.domain.recruitment.dto.RecruitmentUpdateRequest; +import com.slatto.domain.recruitment.entity.Recruitment; +import com.slatto.domain.recruitment.exception.RecruitmentErrorCode; +import com.slatto.domain.recruitment.repository.RecruitmentRepository; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.CategoryName; +import com.slatto.domain.user.enums.RegionName; +import com.slatto.domain.user.enums.RoleName; +import com.slatto.domain.user.enums.SocialType; +import com.slatto.global.exception.BaseException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Spy; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class RecruitmentCloseEditTest { + + private static final Long WRITER_ID = 1L; + private static final Long RECRUITMENT_ID = 10L; + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().registerModule(new JavaTimeModule()); + + @Mock + private RecruitmentRepository recruitmentRepository; + + // 마감 판정 로직을 그대로 써야 검증이 의미가 있어 실제 구현을 넣는다. + @Spy + private RecruitmentConverter recruitmentConverter = new RecruitmentConverter(); + + @InjectMocks + private RecruitmentService recruitmentService; + + @Test + @DisplayName("마감된 공고의 내용 수정은 거부한다") + void rejectsContentEditOnClosedRecruitment() { + givenRecruitment(LocalDate.now().minusDays(1)); + + assertThatThrownBy(() -> recruitmentService.updateRecruitment( + WRITER_ID, RECRUITMENT_ID, request("{\"title\":\"새 제목으로 변경\"}") + )) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE); + } + + // status 만 RECRUITING 으로 보내도 마감일이 과거면 여전히 마감이다. + // 이걸 통과시키면 같은 요청에 실린 내용 변경까지 반영돼 수정 금지가 우회된다. + @Test + @DisplayName("마감일이 과거인데 status 만 RECRUITING 으로 보내면 거부한다") + void rejectsReopenWithoutFutureDeadline() { + givenRecruitment(LocalDate.now().minusDays(1)); + + assertThatThrownBy(() -> recruitmentService.updateRecruitment( + WRITER_ID, RECRUITMENT_ID, + request("{\"status\":\"RECRUITING\",\"title\":\"우회로 바꾼 제목\"}") + )) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE); + } + + @Test + @DisplayName("과거 마감일을 그대로 다시 보내도 거부한다") + void rejectsReopenWithPastDeadline() { + givenRecruitment(LocalDate.now().minusDays(1)); + + assertThatThrownBy(() -> recruitmentService.updateRecruitment( + WRITER_ID, RECRUITMENT_ID, + request("{\"status\":\"RECRUITING\",\"deadline\":\"" + + LocalDate.now().minusDays(3) + "\"}") + )) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(RecruitmentErrorCode.RECRUITMENT_CLOSED_NOT_EDITABLE); + } + + private void givenRecruitment(LocalDate deadline) { + Users writer = Users.createSocialUser("writer@slatto.com", "작성자", null, SocialType.GOOGLE, "social-1"); + ReflectionTestUtils.setField(writer, "id", WRITER_ID); + + Recruitment recruitment = Recruitment.create( + writer, + "단편영화 촬영감독 구합니다", + CategoryName.FILM_DRAMA, + LengthType.SHORT_FORM, + RoleName.CINEMATOGRAPHER, + RegionName.SEOUL, + "2026년 9월", + "협의", + "010-0000-0000", + "촬영 인력을 모집합니다.", + deadline + ); + ReflectionTestUtils.setField(recruitment, "id", RECRUITMENT_ID); + + when(recruitmentRepository.findActiveWithWriterById(RECRUITMENT_ID)) + .thenReturn(Optional.of(recruitment)); + } + + private RecruitmentUpdateRequest request(String json) { + try { + return OBJECT_MAPPER.readValue(json, RecruitmentUpdateRequest.class); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + +} diff --git a/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java b/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java index 59038e36..97fbfc45 100644 --- a/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java +++ b/src/test/java/com/slatto/domain/user/service/PortfolioUpdatedAtTest.java @@ -23,8 +23,12 @@ import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.test.context.TestPropertySource; import java.time.LocalDateTime; @@ -33,7 +37,8 @@ import static org.assertj.core.api.Assertions.assertThat; @DataJpaTest -@Import({PortfolioService.class, UserService.class, YoutubeUrlParser.class}) +@Import({PortfolioService.class, UserService.class, YoutubeUrlParser.class, + PortfolioUpdatedAtTest.PasswordEncoderTestConfig.class}) @TestPropertySource(properties = { "spring.jpa.database=h2", "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", @@ -43,6 +48,15 @@ class PortfolioUpdatedAtTest { private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + @TestConfiguration + static class PasswordEncoderTestConfig { + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + } + // UserService가 프로필 이미지 업로드용 StorageService를 의존하므로, 이 JPA 슬라이스에서는 빈만 제공한다. @MockBean private StorageService storageService; diff --git a/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java b/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java index 8abbfcb7..1b87f6d1 100644 --- a/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java +++ b/src/test/java/com/slatto/domain/user/service/UserProfileImageServiceTest.java @@ -1,6 +1,11 @@ package com.slatto.domain.user.service; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.slatto.domain.auth.repository.RefreshTokenRepository; +import com.slatto.domain.recruitment.repository.RecruitmentRepository; import com.slatto.domain.user.dto.UserProfileImageResponse; +import com.slatto.domain.user.dto.UserWithdrawRequest; +import com.slatto.domain.user.repository.UserPortfolioRepository; import com.slatto.domain.user.entity.Users; import com.slatto.domain.user.enums.SocialType; import com.slatto.domain.user.repository.LocationRepository; @@ -18,6 +23,8 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; import java.util.Optional; @@ -45,6 +52,15 @@ class UserProfileImageServiceTest { @Mock private LocationRepository locationRepository; + @Mock + private UserPortfolioRepository userPortfolioRepository; + + @Mock + private RecruitmentRepository recruitmentRepository; + + @Mock + private RefreshTokenRepository refreshTokenRepository; + @Mock private StorageService storageService; @@ -90,6 +106,37 @@ void setUp() { verify(storageService, never()).upload(any(), anyString()); } + // URL 만 비우면 스토리지 객체가 남아 기존 공개 URL 을 아는 사람은 탈퇴 후에도 사진을 볼 수 있다. + @Test + void 탈퇴하면_프로필_이미지_객체도_삭제한다() { + String storageKey = "users/1/profile-images/abc.png"; + Users user = user(1L, "https://cdn.slatto.cloud/" + storageKey); + when(userRepository.findByIdAndDeletedAtIsNull(1L)).thenReturn(Optional.of(user)); + + // 삭제는 커밋 이후에 실행된다. 실제 트랜잭션이 없는 슬라이스라 동기화를 직접 열고 닫는다. + TransactionSynchronizationManager.initSynchronization(); + try { + userService.withdraw(1L, withdrawRequest()); + + TransactionSynchronizationManager.getSynchronizations() + .forEach(synchronization -> + synchronization.afterCompletion(TransactionSynchronization.STATUS_COMMITTED)); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + + verify(storageService).delete(storageKey); + assertThat(user.getProfileImageUrl()).isNull(); + } + + private UserWithdrawRequest withdrawRequest() { + try { + return new ObjectMapper().readValue("{\"agreed\":true}", UserWithdrawRequest.class); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + private Users user(Long id, String profileImageUrl) { Users user = Users.createSocialUser( "green@example.com", "그린", profileImageUrl, SocialType.GOOGLE, "google-green" diff --git a/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java b/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java new file mode 100644 index 00000000..05743600 --- /dev/null +++ b/src/test/java/com/slatto/domain/user/service/UserWithdrawTest.java @@ -0,0 +1,269 @@ +package com.slatto.domain.user.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.slatto.domain.auth.entity.RefreshToken; +import com.slatto.domain.auth.repository.RefreshTokenRepository; +import com.slatto.domain.project.enums.LengthType; +import com.slatto.domain.recruitment.entity.Recruitment; +import com.slatto.domain.recruitment.repository.RecruitmentRepository; +import com.slatto.domain.user.dto.UserWithdrawRequest; +import com.slatto.domain.user.entity.UserPortfolio; +import com.slatto.domain.user.entity.Users; +import com.slatto.domain.user.enums.CategoryName; +import com.slatto.domain.user.enums.Kind; +import com.slatto.domain.user.enums.RegionName; +import com.slatto.domain.user.enums.RoleName; +import com.slatto.domain.user.enums.SocialType; +import com.slatto.domain.user.repository.UserPortfolioRepository; +import com.slatto.domain.user.exception.UserErrorCode; +import com.slatto.domain.user.repository.UserRepository; +import com.slatto.domain.video.util.YoutubeUrlParser; +import com.slatto.global.exception.BaseException; +import com.slatto.global.storage.StorageService; +import jakarta.persistence.EntityManager; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest +@Import({UserService.class, YoutubeUrlParser.class, UserWithdrawTest.PasswordEncoderTestConfig.class}) +@TestPropertySource(properties = { + "spring.jpa.database=h2", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop" +}) +class UserWithdrawTest { + + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final String EMAIL = "tester@slatto.com"; + private static final String PASSWORD = "slatto!2026"; + + @TestConfiguration + static class PasswordEncoderTestConfig { + + @Bean + PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + } + + // UserService 가 프로필 이미지 업로드용으로 의존한다. 이 슬라이스에서는 빈만 제공한다. + @MockitoBean + private StorageService storageService; + + @Autowired + private UserService userService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private UserPortfolioRepository userPortfolioRepository; + + @Autowired + private RecruitmentRepository recruitmentRepository; + + @Autowired + private RefreshTokenRepository refreshTokenRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private EntityManager entityManager; + + private Long userId; + private Long portfolioId; + private Long recruitmentId; + + @BeforeEach + void setUp() { + Users user = userRepository.save( + Users.createSocialUser(EMAIL, "테스터", "https://cdn.test/a.png", SocialType.GOOGLE, "social-1") + ); + userId = user.getId(); + + UserPortfolio portfolio = userPortfolioRepository.save(UserPortfolio.create( + user, + "연애혁명", + CategoryName.FILM_DRAMA, + null, + Kind.EXTERNAL, + "스튜디오 X", + "웹드라마 연출", + "감정선 중심으로 작업", + "https://www.youtube.com/watch?v=abcdefghijk", + "https://img.youtube.com/vi/abcdefghijk/hqdefault.jpg" + )); + portfolioId = portfolio.getId(); + + Recruitment recruitment = recruitmentRepository.save(Recruitment.create( + user, + "단편영화 촬영감독 구합니다", + CategoryName.FILM_DRAMA, + LengthType.SHORT_FORM, + RoleName.CINEMATOGRAPHER, + RegionName.SEOUL, + "2026년 9월", + "협의", + "010-0000-0000", + "단편영화 촬영 인력을 모집합니다.", + LocalDate.now().plusDays(30) + )); + recruitmentId = recruitment.getId(); + + refreshTokenRepository.save( + RefreshToken.issue(user, "refresh-token-value", LocalDateTime.now().plusDays(14)) + ); + + entityManager.flush(); + entityManager.clear(); + } + + @Test + @DisplayName("탈퇴하면 개인정보를 익명화하고 삭제 시각을 남긴다") + void anonymizesPersonalDataOnWithdraw() { + userService.withdraw(userId, withdrawRequest()); + entityManager.flush(); + entityManager.clear(); + + Users withdrawn = userRepository.findById(userId).orElseThrow(); + + assertThat(withdrawn.getDeletedAt()).isNotNull(); + assertThat(withdrawn.getEmail()).isEqualTo("withdrawn_" + userId + "@slatto.invalid"); + assertThat(withdrawn.getNickname()).isEqualTo("탈퇴한 사용자_" + userId); + assertThat(withdrawn.getSocialId()).isNull(); + assertThat(withdrawn.getProfileImageUrl()).isNull(); + assertThat(withdrawn.getBio()).isNull(); + } + + // 유저 행 자체는 남겨야 한다. 프로젝트·공고가 FK 로 참조하고 있어 지우면 이력이 끊긴다. + @Test + @DisplayName("탈퇴해도 유저 행은 남는다") + void keepsUserRowOnWithdraw() { + userService.withdraw(userId, withdrawRequest()); + entityManager.flush(); + entityManager.clear(); + + assertThat(userRepository.findById(userId)).isPresent(); + assertThat(userRepository.findByIdAndDeletedAtIsNull(userId)).isEmpty(); + } + + @Test + @DisplayName("탈퇴하면 포트폴리오도 함께 내려간다") + void softDeletesPortfoliosOnWithdraw() { + userService.withdraw(userId, withdrawRequest()); + entityManager.flush(); + entityManager.clear(); + + assertThat(userPortfolioRepository.findByIdAndUserIdAndDeletedAtIsNull(portfolioId, userId)).isEmpty(); + assertThat(userPortfolioRepository.findById(portfolioId).orElseThrow().getDeletedAt()).isNotNull(); + } + + // 원래 이메일이 지워져야 같은 주소로 다시 가입할 수 있다. 유니크 제약이 남으면 영구히 막힌다. + @Test + @DisplayName("탈퇴 후에는 같은 이메일이 조회되지 않아 재가입할 수 있다") + void releasesEmailOnWithdraw() { + userService.withdraw(userId, withdrawRequest()); + entityManager.flush(); + entityManager.clear(); + + assertThat(userRepository.findByEmail(EMAIL)).isEmpty(); + } + + // 작성자가 없는 공고가 목록에 남으면 지원자가 응답받을 수 없는 곳에 지원하게 된다. + @Test + @DisplayName("탈퇴하면 작성한 공고도 함께 내려간다") + void softDeletesRecruitmentsOnWithdraw() { + userService.withdraw(userId, withdrawRequest()); + entityManager.flush(); + entityManager.clear(); + + assertThat(recruitmentRepository.findById(recruitmentId).orElseThrow().getDeletedAt()).isNotNull(); + } + + @Test + @DisplayName("탈퇴하면 리프레시 토큰이 삭제된다") + void deletesRefreshTokenOnWithdraw() { + userService.withdraw(userId, withdrawRequest()); + entityManager.flush(); + entityManager.clear(); + + assertThat(refreshTokenRepository.findByToken("refresh-token-value")).isEmpty(); + } + + // 세션만으로 탈퇴가 되면 토큰이 탈취된 상황에서 계정을 통째로 지워버릴 수 있다. + @Test + @DisplayName("비밀번호가 있는 계정은 비밀번호가 틀리면 탈퇴할 수 없다") + void rejectsWithdrawWithWrongPassword() { + Users emailUser = userRepository.save( + Users.createEmailUser("email@slatto.com", "이메일유저", passwordEncoder.encode(PASSWORD)) + ); + entityManager.flush(); + entityManager.clear(); + + assertThatThrownBy(() -> userService.withdraw(emailUser.getId(), withdrawRequest("wrong!2026"))) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(UserErrorCode.WITHDRAW_PASSWORD_MISMATCH); + } + + // BCryptPasswordEncoder.matches 는 raw 가 null 이면 예외를 던져 500 이 나간다. + @Test + @DisplayName("비밀번호가 있는 계정이 비밀번호를 보내지 않으면 401 로 거부한다") + void rejectsWithdrawWithoutPassword() { + Users emailUser = userRepository.save( + Users.createEmailUser("nopass@slatto.com", "이메일유저", passwordEncoder.encode(PASSWORD)) + ); + entityManager.flush(); + entityManager.clear(); + + assertThatThrownBy(() -> userService.withdraw(emailUser.getId(), withdrawRequest(null))) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(UserErrorCode.WITHDRAW_PASSWORD_MISMATCH); + } + + // 소셜로만 가입한 계정은 확인할 비밀번호가 없다. 필수로 걸면 탈퇴 자체가 막힌다. + @Test + @DisplayName("비밀번호가 없는 소셜 계정은 비밀번호 없이 탈퇴할 수 있다") + void allowsWithdrawWithoutPasswordForSocialAccount() { + userService.withdraw(userId, withdrawRequest(null)); + entityManager.flush(); + entityManager.clear(); + + assertThat(userRepository.findById(userId).orElseThrow().getDeletedAt()).isNotNull(); + } + + private UserWithdrawRequest withdrawRequest() { + return withdrawRequest(null); + } + + private UserWithdrawRequest withdrawRequest(String password) { + try { + String json = password == null + ? "{\"agreed\":true}" + : "{\"agreed\":true,\"password\":\"" + password + "\"}"; + + return OBJECT_MAPPER.readValue(json, UserWithdrawRequest.class); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + } + +} diff --git a/src/test/java/com/slatto/global/security/JwtAuthenticationFilterTest.java b/src/test/java/com/slatto/global/security/JwtAuthenticationFilterTest.java new file mode 100644 index 00000000..55e04f59 --- /dev/null +++ b/src/test/java/com/slatto/global/security/JwtAuthenticationFilterTest.java @@ -0,0 +1,98 @@ +package com.slatto.global.security; + +import com.slatto.domain.user.repository.UserRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.mock.web.MockFilterChain; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; + +class JwtAuthenticationFilterTest { + + private static final Long USER_ID = 42L; + private static final String TOKEN = "valid-access-token"; + + private JwtTokenProvider jwtTokenProvider; + private UserRepository userRepository; + private JwtAuthenticationFilter filter; + + @BeforeEach + void setUp() { + jwtTokenProvider = mock(JwtTokenProvider.class); + userRepository = mock(UserRepository.class); + filter = new JwtAuthenticationFilter(jwtTokenProvider, userRepository); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + @DisplayName("활성 유저의 토큰이면 인증을 세운다") + void authenticatesActiveUser() throws Exception { + given(jwtTokenProvider.parseUserId(eq(TOKEN), eq(false))).willReturn(USER_ID); + given(userRepository.existsByIdAndDeletedAtIsNull(USER_ID)).willReturn(true); + + doFilterWithToken(); + + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + assertThat(authentication).isNotNull(); + assertThat(authentication.getPrincipal()).isEqualTo(USER_ID); + } + + // 탈퇴해도 이미 발급된 토큰은 만료까지 서명 검증을 통과한다. + // 여기서 걸러내지 않으면 탈퇴 후에도 그 시간 동안 API 가 열린다. + @Test + @DisplayName("탈퇴한 유저의 토큰이면 인증을 세우지 않는다") + void rejectsWithdrawnUser() throws Exception { + given(jwtTokenProvider.parseUserId(eq(TOKEN), eq(false))).willReturn(USER_ID); + given(userRepository.existsByIdAndDeletedAtIsNull(USER_ID)).willReturn(false); + + doFilterWithToken(); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + } + + @Test + @DisplayName("토큰이 유효하지 않으면 유저를 조회하지 않는다") + void skipsUserLookupForInvalidToken() throws Exception { + given(jwtTokenProvider.parseUserId(anyString(), eq(false))).willReturn(null); + + doFilterWithToken(); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(userRepository, never()).existsByIdAndDeletedAtIsNull(anyLong()); + } + + @Test + @DisplayName("Authorization 헤더가 없으면 유저를 조회하지 않는다") + void skipsUserLookupWithoutHeader() throws Exception { + filter.doFilter(new MockHttpServletRequest(), new MockHttpServletResponse(), new MockFilterChain()); + + assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); + verify(userRepository, never()).existsByIdAndDeletedAtIsNull(anyLong()); + } + + private void doFilterWithToken() throws Exception { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader(HttpHeaders.AUTHORIZATION, "Bearer " + TOKEN); + + filter.doFilter(request, new MockHttpServletResponse(), new MockFilterChain()); + } + +}