From ed502f9e047909fac01533802e3889653483bb6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EC=A4=80?= Date: Sun, 10 Aug 2025 16:10:39 +0900 Subject: [PATCH 1/3] =?UTF-8?q?[REFACTOR]=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8/=EC=B9=B4=EC=B9=B4=EC=98=A4=20=EC=9E=AC=EC=9D=B8?= =?UTF-8?q?=EC=A6=9D=20=EB=A6=AC=ED=8C=A9=ED=84=B0=EB=A7=81=20=EB=B0=8F=20?= =?UTF-8?q?=EB=AC=B8=EC=84=9C=20=EA=B0=95=ED=99=94=20#102?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/controller/ReauthController.java | 14 +++- .../user/dto/request/ReauthKakaoRequest.java | 4 +- .../dto/request/ReauthPasswordRequest.java | 4 +- .../user/dto/response/ReauthResponse.java | 7 +- .../service/AuthService/ReauthService.java | 73 ++++++++++--------- .../global/api/code/status/ErrorStatus.java | 6 ++ 6 files changed, 68 insertions(+), 40 deletions(-) diff --git a/src/main/java/com/indayvidual/server/domain/user/controller/ReauthController.java b/src/main/java/com/indayvidual/server/domain/user/controller/ReauthController.java index 81070f4..acdd047 100644 --- a/src/main/java/com/indayvidual/server/domain/user/controller/ReauthController.java +++ b/src/main/java/com/indayvidual/server/domain/user/controller/ReauthController.java @@ -12,6 +12,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -21,10 +22,15 @@ @RestController @RequestMapping("/api/auth/re-auth") @RequiredArgsConstructor +@Validated public class ReauthController { private final ReauthService reauthService; - @Operation(summary = "비밀번호 재인증", description = "현재 비밀번호 검증 후 reauth_token 발급(TTL=5분)") + @Operation(summary = "비밀번호 재인증", description = "현재 비밀번호 검증 후 reauth_token 발급(TTL=10분)") + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "재인증 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "비밀번호 불일치 / 재인증 실패") + }) @PostMapping("/password") public ResponseEntity> reauthByPassword( @AuthenticationPrincipal JwtUserPrincipal principal, @@ -33,7 +39,11 @@ public ResponseEntity> reauthByPassword( return ResponseEntity.ok(ApiResponse.onSuccess(res)); } - @Operation(summary = "카카오 재인증", description = "카카오 accessToken 검증 후 reauth_token 발급(TTL=5분)") + @Operation(summary = "카카오 재인증", description = "카카오 accessToken 검증 후 reauth_token 발급(TTL=10분)") + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "재인증 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "연동된 카카오 계정 아님 / 토큰 무효") + }) @PostMapping("/kakao") public ResponseEntity> reauthByKakao( @AuthenticationPrincipal JwtUserPrincipal principal, diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthKakaoRequest.java b/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthKakaoRequest.java index 63b43fb..cd5562f 100644 --- a/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthKakaoRequest.java +++ b/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthKakaoRequest.java @@ -1,11 +1,13 @@ package com.indayvidual.server.domain.user.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.Getter; @Getter public class ReauthKakaoRequest { - @NotBlank + @Schema(description = "카카오 액세스 토큰", example = "eyJhbGciOiJIUzI1NiJ9.KAKAO_ACCESS", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "kakaoAccessToken은 필수입니다.") private String kakaoAccessToken; } diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthPasswordRequest.java b/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthPasswordRequest.java index 8d7cdfd..317a6c4 100644 --- a/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthPasswordRequest.java +++ b/src/main/java/com/indayvidual/server/domain/user/dto/request/ReauthPasswordRequest.java @@ -1,10 +1,12 @@ package com.indayvidual.server.domain.user.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import lombok.Getter; @Getter public class ReauthPasswordRequest { - @NotBlank + @Schema(description = "현재 비밀번호", example = "P@ssw0rd123!", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "currentPassword는 필수입니다.") private String currentPassword; } \ No newline at end of file diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/response/ReauthResponse.java b/src/main/java/com/indayvidual/server/domain/user/dto/response/ReauthResponse.java index 5ea0239..e949deb 100644 --- a/src/main/java/com/indayvidual/server/domain/user/dto/response/ReauthResponse.java +++ b/src/main/java/com/indayvidual/server/domain/user/dto/response/ReauthResponse.java @@ -1,11 +1,14 @@ package com.indayvidual.server.domain.user.dto.response; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Getter; @Getter @Builder public class ReauthResponse { - private String reauthToken; // UUID - private long expiresInSeconds; // 예: 300 + @Schema(description = "재인증 토큰(Base64URL)", example = "dGhpcy1pcy1yZWF1dGgtdG9rZW4", requiredMode = Schema.RequiredMode.REQUIRED) + private String reauthToken; + @Schema(description = "만료까지 남은 초", example = "600", requiredMode = Schema.RequiredMode.REQUIRED) + private long expiresInSeconds; } diff --git a/src/main/java/com/indayvidual/server/domain/user/service/AuthService/ReauthService.java b/src/main/java/com/indayvidual/server/domain/user/service/AuthService/ReauthService.java index 427d6f2..4ef2b13 100644 --- a/src/main/java/com/indayvidual/server/domain/user/service/AuthService/ReauthService.java +++ b/src/main/java/com/indayvidual/server/domain/user/service/AuthService/ReauthService.java @@ -1,69 +1,74 @@ package com.indayvidual.server.domain.user.service.AuthService; -import com.indayvidual.server.domain.user.dto.external.KakaoUserInfo; import com.indayvidual.server.domain.user.dto.response.ReauthResponse; import com.indayvidual.server.domain.user.entity.User; import com.indayvidual.server.domain.user.entity.UserProvider; import com.indayvidual.server.domain.user.entity.enums.Provider; import com.indayvidual.server.domain.user.repository.UserProviderRepository; import com.indayvidual.server.domain.user.repository.UserRepository; +import com.indayvidual.server.global.api.code.status.ErrorStatus; +import com.indayvidual.server.global.client.KakaoApiClient; +import com.indayvidual.server.global.exception.GeneralException; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.data.redis.core.StringRedisTemplate; -import org.springframework.http.HttpHeaders; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; -import org.springframework.web.reactive.function.client.WebClient;// ReauthService.java 내 -import org.springframework.http.HttpStatusCode; + + +import java.security.SecureRandom; import java.time.Duration; import java.time.Instant; +import java.util.Base64; import java.util.Objects; -import java.util.UUID; @Service @RequiredArgsConstructor public class ReauthService { - @Value("${kakao.api-base}") - private String kakaoApiBase; - private final UserRepository userRepository; private final UserProviderRepository userProviderRepository; private final PasswordEncoder passwordEncoder; private final StringRedisTemplate redis; // Lettuce/Jedis 아무거나 + private final KakaoApiClient kakaoApiClient; - private static final Duration REAUTH_TTL = Duration.ofMinutes(5); + private static final Duration REAUTH_TTL = Duration.ofMinutes(10); + private static final SecureRandom RNG = new SecureRandom(); public ReauthResponse reauthByPassword(Long userId, String currentPassword) { User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new GeneralException(ErrorStatus.REAUTH_USER_NOT_FOUND)); if (user.getPassword() == null || !passwordEncoder.matches(currentPassword, user.getPassword())) { - throw new IllegalArgumentException("비밀번호가 일치하지 않습니다."); + throw new GeneralException(ErrorStatus.REAUTH_PASSWORD_MISMATCH); } return issueToken(userId, "PASSWORD"); } public ReauthResponse reauthByKakao(Long userId, String kakaoAccessToken) { // 1) kakaoAccessToken 으로 카카오 유저 정보 조회 (userinfo) - KakaoUserInfo info = fetchKakaoUserInfo(kakaoAccessToken); // 아래 참고 + var profile = kakaoApiClient.fetchProfile(kakaoAccessToken); // 아래 참고 // 2) 우리 시스템에 연동된 카카오 계정인지 확인 User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new GeneralException(ErrorStatus.REAUTH_USER_NOT_FOUND)); boolean linked = userProviderRepository .findByUserAndProvider(user, Provider.KAKAO) .filter(UserProvider::getIsActive) - .map(up -> Objects.equals(up.getProviderId(), String.valueOf(info.getId()))) + .map(up -> Objects.equals(up.getProviderId(), String.valueOf(profile.getId()))) .orElse(false); - if (!linked) throw new IllegalArgumentException("연결된 카카오 계정이 아닙니다."); + if (!linked) throw new GeneralException(ErrorStatus.REAUTH_PROVIDER_NOT_LINKED); return issueToken(userId, "KAKAO"); } private ReauthResponse issueToken(Long userId, String method) { - String token = UUID.randomUUID().toString(); + //String token = UUID.randomUUID().toString(); + // 더 예측 어려운 토큰(Base64URL 32바이트) + byte[] buf = new byte[32]; + RNG.nextBytes(buf); + String token = Base64.getUrlEncoder().withoutPadding().encodeToString(buf); + String key = key(userId, token); String value = "{\"method\":\"" + method + "\",\"ts\":\"" + Instant.now() + "\"}"; @@ -77,7 +82,7 @@ private ReauthResponse issueToken(Long userId, String method) { public void assertReauthOrThrow(Long userId, String reauthToken, boolean consumeOnce) { String key = key(userId, reauthToken); String v = redis.opsForValue().get(key); - if (v == null) throw new IllegalArgumentException("재인증이 필요합니다."); + if (v == null) throw new GeneralException(ErrorStatus.REAUTH_REQUIRED); if (consumeOnce) { redis.delete(key); @@ -88,20 +93,20 @@ private String key(Long userId, String token) { return "reauth:" + userId + ":" + token; } - // --- Kakao userinfo 호출 --- - private KakaoUserInfo fetchKakaoUserInfo(String kakaoAccessToken) { - WebClient web = WebClient.builder() - .baseUrl(kakaoApiBase) - .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + kakaoAccessToken) - .build(); - - return web.get() - .uri("/v2/user/me") - .retrieve() - .onStatus(HttpStatusCode::isError, rsp -> - rsp.bodyToMono(String.class) - .map(body -> new IllegalArgumentException("Kakao 인증 실패: " + body))) - .bodyToMono(KakaoUserInfo.class) - .block(); - } +// // --- Kakao userinfo 호출 --- +// private KakaoUserInfo fetchKakaoUserInfo(String kakaoAccessToken) { +// WebClient web = WebClient.builder() +// .baseUrl(kakaoApiBase) +// .defaultHeader(HttpHeaders.AUTHORIZATION, "Bearer " + kakaoAccessToken) +// .build(); +// +// return web.get() +// .uri("/v2/user/me") +// .retrieve() +// .onStatus(HttpStatusCode::isError, rsp -> +// rsp.bodyToMono(String.class) +// .map(body -> new IllegalArgumentException("Kakao 인증 실패: " + body))) +// .bodyToMono(KakaoUserInfo.class) +// .block(); +// } } diff --git a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java index a63d8b6..d3db8f7 100644 --- a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java +++ b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java @@ -60,6 +60,12 @@ public enum ErrorStatus implements BaseErrorCode { EMAIL_CODE_MISMATCH(HttpStatus.BAD_REQUEST, "EMAIL4001", "인증번호가 일치하지 않습니다."), EMAIL_CODE_EXPIRED(HttpStatus.GONE, "EMAIL4100", "인증번호가 만료되었습니다."), + // ===== RE-AUTH ===== + REAUTH_REQUIRED(HttpStatus.UNAUTHORIZED, "REAUTH4010", "재인증이 필요합니다."), + REAUTH_PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "REAUTH4011", "비밀번호가 일치하지 않습니다."), + REAUTH_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "REAUTH4040", "사용자를 찾을 수 없습니다."), + REAUTH_PROVIDER_NOT_LINKED(HttpStatus.UNAUTHORIZED, "REAUTH4012", "연결된 카카오 계정이 아닙니다."), + // 계정 관련 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER4041", "사용자를 찾을 수 없습니다."), USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER4091", "이미 존재하는 사용자입니다."), From 383a769218cd3e1c76915c3f0036c4540687541f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EC=A4=80?= Date: Sun, 10 Aug 2025 16:51:50 +0900 Subject: [PATCH 2/3] =?UTF-8?q?[REFACTOR]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20#10?= =?UTF-8?q?2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/UserProfileController.java | 53 +++++++++++++++++++ .../dto/request/DeleteAccountRequest.java | 2 + .../user/dto/request/UserRequestDTO.java | 12 +++-- .../UserService/UserProfileService.java | 38 ++++++++++--- .../global/api/code/status/ErrorStatus.java | 9 ++++ 5 files changed, 102 insertions(+), 12 deletions(-) diff --git a/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java b/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java index ee82237..7450e12 100644 --- a/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java +++ b/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java @@ -9,12 +9,17 @@ import com.indayvidual.server.global.api.response.ApiResponse; import com.indayvidual.server.global.config.security.JwtUserPrincipal; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; @@ -22,13 +27,26 @@ @RestController @RequestMapping("/api/mypage") @RequiredArgsConstructor +@SecurityRequirement(name = "bearerAuth") +@Validated public class UserProfileController { private final UserProfileService userProfileService; private final ReauthService reauthService; + @Operation( + summary = "내 프로필 조회", + description = "재인증 토큰(X-Reauth-Token) 검증 후 내 프로필을 반환합니다." + ) + @io.swagger.v3.oas.annotations.responses.ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "재인증 필요 혹은 인증 실패"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자 없음") + }) @GetMapping("/profile") public ResponseEntity> getMyProfile( + @Parameter(name = "X-Reauth-Token", in = ParameterIn.HEADER, required = true, + description = "재인증 토큰", example = "reauth_base64url_token") @RequestHeader("X-Reauth-Token") String reauthToken, @AuthenticationPrincipal JwtUserPrincipal principal ) { @@ -37,9 +55,17 @@ public ResponseEntity> getMyProfile( return ResponseEntity.ok(ApiResponse.onSuccess(profile)); } + @Operation(summary = "닉네임 변경", description = "재인증 토큰 검증 후 닉네임을 변경합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "변경 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "재인증 필요"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자 없음") + }) @PatchMapping("/update_username") public ResponseEntity> updateUsername( @AuthenticationPrincipal JwtUserPrincipal principal, + @Parameter(name = "X-Reauth-Token", in = ParameterIn.HEADER, required = true, + description = "재인증 토큰", example = "reauth_base64url_token") @RequestHeader("X-Reauth-Token") String reauthToken, @RequestBody @Valid UserRequestDTO.UpdateUsername dto ) { @@ -48,9 +74,18 @@ public ResponseEntity> updateUsername( return ResponseEntity.ok(ApiResponse.onSuccess("닉네임이 변경되었습니다.")); } + @Operation(summary = "비밀번호 변경", description = "재인증 토큰 1회용 검증 후 비밀번호를 변경합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "변경 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "새 비밀번호가 현재와 동일"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "재인증 필요 또는 현재 비밀번호 불일치"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자 없음") + }) @PatchMapping("/update_password") public ResponseEntity> updatePassword( @AuthenticationPrincipal JwtUserPrincipal principal, + @Parameter(name = "X-Reauth-Token", in = ParameterIn.HEADER, required = true, + description = "재인증 토큰", example = "reauth_base64url_token") @RequestHeader("X-Reauth-Token") String reauthToken, @RequestBody @Valid UserRequestDTO.UpdatePassword dto ) { @@ -59,9 +94,20 @@ public ResponseEntity> updatePassword( return ResponseEntity.ok(ApiResponse.onSuccess("비밀번호가 변경되었습니다.")); } + @Operation(summary = "프로필 이미지 변경", + description = "재인증 토큰 검증 후 이미지를 업로드합니다. 허용 형식: JPEG/PNG/WEBP, 최대 5MB.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "변경 성공 (프리사인드 URL 반환)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "잘못된 파일 혹은 허용되지 않은 형식"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "재인증 필요"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "413", description = "파일 용량 초과"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "502", description = "업로드 실패") + }) @PatchMapping(value = "/profile-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public ResponseEntity> updateProfileImage( @AuthenticationPrincipal JwtUserPrincipal principal, + @Parameter(name = "X-Reauth-Token", in = ParameterIn.HEADER, required = true, + description = "재인증 토큰", example = "reauth_base64url_token") @RequestHeader("X-Reauth-Token") String reauthToken, @RequestPart("image") MultipartFile image ) { @@ -71,9 +117,16 @@ public ResponseEntity> updateProfileImage( } @Operation(summary = "회원 탈퇴", description = "재인증 토큰(X-Reauth-Token) 필요. 기본은 소프트 삭제") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "탈퇴 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "재인증 필요"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자 없음") + }) @DeleteMapping("/delete") public ResponseEntity> deleteMe( @AuthenticationPrincipal JwtUserPrincipal principal, + @Parameter(name = "X-Reauth-Token", in = ParameterIn.HEADER, required = true, + description = "재인증 토큰", example = "reauth_base64url_token") @RequestHeader("X-Reauth-Token") String reauthToken, @RequestBody(required = false) DeleteAccountRequest req ) { diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/request/DeleteAccountRequest.java b/src/main/java/com/indayvidual/server/domain/user/dto/request/DeleteAccountRequest.java index c93bc7b..fc76dda 100644 --- a/src/main/java/com/indayvidual/server/domain/user/dto/request/DeleteAccountRequest.java +++ b/src/main/java/com/indayvidual/server/domain/user/dto/request/DeleteAccountRequest.java @@ -1,8 +1,10 @@ package com.indayvidual.server.domain.user.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; @Getter public class DeleteAccountRequest { + @Schema(description = "true면 하드 삭제, false면 소프트 삭제", example = "false") private boolean hard; // true면 하드 삭제 } diff --git a/src/main/java/com/indayvidual/server/domain/user/dto/request/UserRequestDTO.java b/src/main/java/com/indayvidual/server/domain/user/dto/request/UserRequestDTO.java index 3a33657..a2c6a33 100644 --- a/src/main/java/com/indayvidual/server/domain/user/dto/request/UserRequestDTO.java +++ b/src/main/java/com/indayvidual/server/domain/user/dto/request/UserRequestDTO.java @@ -1,5 +1,6 @@ package com.indayvidual.server.domain.user.dto.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; import lombok.Getter; @@ -8,16 +9,19 @@ public class UserRequestDTO { @Getter public static class UpdateUsername { - @NotBlank - @Size(min = 2, max = 20) + @Schema(description = "새 닉네임", example = "박감자", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "username은 필수입니다.") + @Size(min = 2, max = 20, message = "닉네임은 2~20자여야 합니다.") private String username; } @Getter public static class UpdatePassword { - @NotBlank + @Schema(description = "현재 비밀번호", example = "oldP@ssw0rd", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "currentPassword는 필수입니다.") private String currentPassword; - @NotBlank + @Schema(description = "새 비밀번호(최소 8자)", example = "NewP@ssw0rd1", requiredMode = Schema.RequiredMode.REQUIRED) + @NotBlank(message = "newPassword는 필수입니다.") @Size(min = 8, message = "비밀번호는 최소 8자 이상이어야 합니다.") private String newPassword; } diff --git a/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java b/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java index db1fa92..6ae8830 100644 --- a/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java +++ b/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java @@ -7,6 +7,8 @@ import com.indayvidual.server.domain.user.repository.RefreshTokenRepository; import com.indayvidual.server.domain.user.repository.UserRepository; import com.indayvidual.server.domain.user.service.AuthService.ReauthService; +import com.indayvidual.server.global.api.code.status.ErrorStatus; +import com.indayvidual.server.global.exception.GeneralException; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.crypto.password.PasswordEncoder; @@ -31,7 +33,7 @@ public class UserProfileService { @Transactional(readOnly = true) public UserResponseDTO.Profile getMyProfile(Long userId) { User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new GeneralException(ErrorStatus.MYPAGE_USER_NOT_FOUND)); String img = (user.getProfile_image() == null || user.getProfile_image().isBlank()) ? defaultProfileImageUrl @@ -47,37 +49,57 @@ public UserResponseDTO.Profile getMyProfile(Long userId) { public void updateUsername(Long userId, String newUsername) { User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new GeneralException(ErrorStatus.MYPAGE_USER_NOT_FOUND)); user.changeUsername(newUsername); } public void updatePassword(Long userId, String currentPassword, String newPassword) { User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new GeneralException(ErrorStatus.MYPAGE_USER_NOT_FOUND)); if (!passwordEncoder.matches(currentPassword, user.getPassword())) { - throw new IllegalArgumentException("현재 비밀번호가 일치하지 않습니다."); + throw new GeneralException(ErrorStatus.MYPAGE_PASSWORD_MISMATCH); } if (passwordEncoder.matches(newPassword, user.getPassword())) { - throw new IllegalArgumentException("기존 비밀번호와 동일합니다."); + throw new GeneralException(ErrorStatus.MYPAGE_PASSWORD_SAME); } user.changePassword(passwordEncoder.encode(newPassword)); } public String updateProfileImage(Long userId, MultipartFile image) { User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new GeneralException(ErrorStatus.MYPAGE_USER_NOT_FOUND)); + + if (image == null || image.isEmpty()) { + throw new GeneralException(ErrorStatus.MYPAGE_IMAGE_EMPTY); + } + + // 허용 형식 & 크기(최대 5MB) + final long MAX = 5L * 1024 * 1024; + String ctype = image.getContentType() == null ? "" : image.getContentType().toLowerCase(); + boolean allowed = ctype.startsWith("image/jpeg") || ctype.startsWith("image/png") || ctype.startsWith("image/webp"); + if (!allowed) throw new GeneralException(ErrorStatus.MYPAGE_IMAGE_TYPE_INVALID); + if (image.getSize() > MAX) throw new GeneralException(ErrorStatus.MYPAGE_IMAGE_TOO_LARGE); String oldUrl = user.getProfile_image(); // 업로드 - String newUrl = s3Uploader.uploadProfileImage(userId, image); + String newUrl; + try { + newUrl = s3Uploader.uploadProfileImage(userId, image); + } catch (GeneralException ge) { + // S3Uploader가 내부 에러를 래핑했을 수 있으니 그대로 전파 + throw ge; + } catch (Exception e) { + throw new GeneralException(ErrorStatus.MYPAGE_IMAGE_UPLOAD_FAILED); + } user.changeProfileImage(newUrl); // 이전 이미지가 기본이미지가 아닌 경우 삭제 if (oldUrl != null && !oldUrl.isBlank() && !oldUrl.equals(defaultProfileImageUrl)) { s3Uploader.deleteObjectByUrl(oldUrl); } + return newUrl; } @@ -88,7 +110,7 @@ public void deleteMyAccount(Long userId, String reauthToken, DeleteAccountReques // 2) 유저 로드 User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); + .orElseThrow(() -> new GeneralException(ErrorStatus.MYPAGE_USER_NOT_FOUND)); // 3) RefreshToken 전부 무효화 refreshTokenRepository.deleteAllByUserId(userId); diff --git a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java index d3db8f7..93dd32f 100644 --- a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java +++ b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java @@ -66,6 +66,15 @@ public enum ErrorStatus implements BaseErrorCode { REAUTH_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "REAUTH4040", "사용자를 찾을 수 없습니다."), REAUTH_PROVIDER_NOT_LINKED(HttpStatus.UNAUTHORIZED, "REAUTH4012", "연결된 카카오 계정이 아닙니다."), + // ===== MYPAGE ===== + MYPAGE_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "MYPAGE4040", "사용자를 찾을 수 없습니다."), + MYPAGE_PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "MYPAGE4010", "현재 비밀번호가 일치하지 않습니다."), + MYPAGE_PASSWORD_SAME(HttpStatus.BAD_REQUEST, "MYPAGE4001", "기존 비밀번호와 동일합니다."), + MYPAGE_IMAGE_EMPTY(HttpStatus.BAD_REQUEST, "MYPAGE4002", "업로드할 이미지가 없습니다."), + MYPAGE_IMAGE_TYPE_INVALID(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "MYPAGE4150", "허용되지 않은 이미지 형식입니다."), + MYPAGE_IMAGE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "MYPAGE4130", "이미지 용량이 허용 범위를 초과했습니다."), + MYPAGE_IMAGE_UPLOAD_FAILED(HttpStatus.BAD_GATEWAY, "MYPAGE5020", "이미지 업로드에 실패했습니다."), + // 계정 관련 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER4041", "사용자를 찾을 수 없습니다."), USER_ALREADY_EXISTS(HttpStatus.CONFLICT, "USER4091", "이미 존재하는 사용자입니다."), From 907c7b9ad288ee399885bea700d39e31adc512a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=B0=95=EC=84=B1=EC=A4=80?= Date: Sun, 10 Aug 2025 17:45:54 +0900 Subject: [PATCH 3/3] =?UTF-8?q?[REFACTOR]=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81=20#10?= =?UTF-8?q?2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/controller/ReauthController.java | 6 ++++-- .../user/controller/UserProfileController.java | 11 ++++------- .../user/service/AuthService/ReauthService.java | 11 ++++++++++- .../service/UserService/UserProfileService.java | 13 +++++++++++++ .../server/global/api/code/status/ErrorStatus.java | 3 +++ 5 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/main/java/com/indayvidual/server/domain/user/controller/ReauthController.java b/src/main/java/com/indayvidual/server/domain/user/controller/ReauthController.java index acdd047..c1fec66 100644 --- a/src/main/java/com/indayvidual/server/domain/user/controller/ReauthController.java +++ b/src/main/java/com/indayvidual/server/domain/user/controller/ReauthController.java @@ -29,7 +29,8 @@ public class ReauthController { @Operation(summary = "비밀번호 재인증", description = "현재 비밀번호 검증 후 reauth_token 발급(TTL=10분)") @io.swagger.v3.oas.annotations.responses.ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "재인증 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "비밀번호 불일치 / 재인증 실패") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "비밀번호 불일치 / 재인증 실패"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "비밀번호 재인증을 사용할 수 없는 계정(소셜-only/LOCAL 미연동)") }) @PostMapping("/password") public ResponseEntity> reauthByPassword( @@ -42,7 +43,8 @@ public ResponseEntity> reauthByPassword( @Operation(summary = "카카오 재인증", description = "카카오 accessToken 검증 후 reauth_token 발급(TTL=10분)") @io.swagger.v3.oas.annotations.responses.ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "재인증 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "연동된 카카오 계정 아님 / 토큰 무효") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "토큰 무효"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "카카오 연동이 없는 계정") }) @PostMapping("/kakao") public ResponseEntity> reauthByKakao( diff --git a/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java b/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java index 7450e12..f9a41ae 100644 --- a/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java +++ b/src/main/java/com/indayvidual/server/domain/user/controller/UserProfileController.java @@ -36,21 +36,17 @@ public class UserProfileController { @Operation( summary = "내 프로필 조회", - description = "재인증 토큰(X-Reauth-Token) 검증 후 내 프로필을 반환합니다." + description = "액세스 토큰만으로 내 프로필을 반환합니다. 재인증 토큰은 필요하지 않습니다." ) @io.swagger.v3.oas.annotations.responses.ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "재인증 필요 혹은 인증 실패"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 실패(액세스 토큰 누락/무효)"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자 없음") }) @GetMapping("/profile") public ResponseEntity> getMyProfile( - @Parameter(name = "X-Reauth-Token", in = ParameterIn.HEADER, required = true, - description = "재인증 토큰", example = "reauth_base64url_token") - @RequestHeader("X-Reauth-Token") String reauthToken, @AuthenticationPrincipal JwtUserPrincipal principal ) { - reauthService.assertReauthOrThrow(principal.userId(), reauthToken, false); var profile = userProfileService.getMyProfile(principal.userId()); return ResponseEntity.ok(ApiResponse.onSuccess(profile)); } @@ -79,7 +75,8 @@ public ResponseEntity> updateUsername( @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "변경 성공"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "새 비밀번호가 현재와 동일"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "재인증 필요 또는 현재 비밀번호 불일치"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자 없음") + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "사용자 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "비밀번호 기반 변경이 불가능한 계정(소셜-only/LOCAL 미연동)") }) @PatchMapping("/update_password") public ResponseEntity> updatePassword( diff --git a/src/main/java/com/indayvidual/server/domain/user/service/AuthService/ReauthService.java b/src/main/java/com/indayvidual/server/domain/user/service/AuthService/ReauthService.java index 4ef2b13..f246715 100644 --- a/src/main/java/com/indayvidual/server/domain/user/service/AuthService/ReauthService.java +++ b/src/main/java/com/indayvidual/server/domain/user/service/AuthService/ReauthService.java @@ -38,6 +38,15 @@ public ReauthResponse reauthByPassword(Long userId, String currentPassword) { User user = userRepository.findById(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.REAUTH_USER_NOT_FOUND)); + // 소셜-only 계정(비밀번호 미설정) 또는 LOCAL 미연동 계정은 차단 + boolean hasLocalProvider = userProviderRepository + .findByUserAndProvider(user, Provider.LOCAL) + .filter(UserProvider::getIsActive) + .isPresent(); + if (!hasLocalProvider || user.getPassword() == null) { + throw new GeneralException(ErrorStatus.REAUTH_NO_LOCAL); + } + if (user.getPassword() == null || !passwordEncoder.matches(currentPassword, user.getPassword())) { throw new GeneralException(ErrorStatus.REAUTH_PASSWORD_MISMATCH); } @@ -57,7 +66,7 @@ public ReauthResponse reauthByKakao(Long userId, String kakaoAccessToken) { .map(up -> Objects.equals(up.getProviderId(), String.valueOf(profile.getId()))) .orElse(false); - if (!linked) throw new GeneralException(ErrorStatus.REAUTH_PROVIDER_NOT_LINKED); + if (!linked) throw new GeneralException(ErrorStatus.REAUTH_NO_KAKAO); return issueToken(userId, "KAKAO"); } diff --git a/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java b/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java index 6ae8830..525e6b6 100644 --- a/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java +++ b/src/main/java/com/indayvidual/server/domain/user/service/UserService/UserProfileService.java @@ -3,8 +3,11 @@ import com.indayvidual.server.domain.user.dto.request.DeleteAccountRequest; import com.indayvidual.server.domain.user.dto.response.UserResponseDTO; import com.indayvidual.server.domain.user.entity.User; +import com.indayvidual.server.domain.user.entity.UserProvider; +import com.indayvidual.server.domain.user.entity.enums.Provider; import com.indayvidual.server.domain.user.entity.enums.Status; import com.indayvidual.server.domain.user.repository.RefreshTokenRepository; +import com.indayvidual.server.domain.user.repository.UserProviderRepository; import com.indayvidual.server.domain.user.repository.UserRepository; import com.indayvidual.server.domain.user.service.AuthService.ReauthService; import com.indayvidual.server.global.api.code.status.ErrorStatus; @@ -22,6 +25,7 @@ public class UserProfileService { private final UserRepository userRepository; + private final UserProviderRepository userProviderRepository; private final RefreshTokenRepository refreshTokenRepository; private final ReauthService reauthService; private final PasswordEncoder passwordEncoder; @@ -57,6 +61,15 @@ public void updatePassword(Long userId, String currentPassword, String newPasswo User user = userRepository.findById(userId) .orElseThrow(() -> new GeneralException(ErrorStatus.MYPAGE_USER_NOT_FOUND)); + // 비밀번호 기반 변경 불가(소셜-only 또는 LOCAL 미연동) 차단 + boolean hasLocalProvider = userProviderRepository + .findByUserAndProvider(user, Provider.LOCAL) + .filter(UserProvider::getIsActive) + .isPresent(); + if (!hasLocalProvider || user.getPassword() == null) { + throw new GeneralException(ErrorStatus.MYPAGE_PASSWORD_NOT_SUPPORTED); + } + if (!passwordEncoder.matches(currentPassword, user.getPassword())) { throw new GeneralException(ErrorStatus.MYPAGE_PASSWORD_MISMATCH); } diff --git a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java index 93dd32f..cb3b5d2 100644 --- a/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java +++ b/src/main/java/com/indayvidual/server/global/api/code/status/ErrorStatus.java @@ -65,6 +65,8 @@ public enum ErrorStatus implements BaseErrorCode { REAUTH_PASSWORD_MISMATCH(HttpStatus.UNAUTHORIZED, "REAUTH4011", "비밀번호가 일치하지 않습니다."), REAUTH_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "REAUTH4040", "사용자를 찾을 수 없습니다."), REAUTH_PROVIDER_NOT_LINKED(HttpStatus.UNAUTHORIZED, "REAUTH4012", "연결된 카카오 계정이 아닙니다."), + REAUTH_NO_LOCAL(HttpStatus.CONFLICT, "REAUTH4091", "비밀번호 재인증을 사용할 수 없는 계정입니다."), + REAUTH_NO_KAKAO(HttpStatus.CONFLICT, "REAUTH4092", "카카오 재인증을 사용할 수 없는 계정입니다."), // ===== MYPAGE ===== MYPAGE_USER_NOT_FOUND(HttpStatus.NOT_FOUND, "MYPAGE4040", "사용자를 찾을 수 없습니다."), @@ -74,6 +76,7 @@ public enum ErrorStatus implements BaseErrorCode { MYPAGE_IMAGE_TYPE_INVALID(HttpStatus.UNSUPPORTED_MEDIA_TYPE, "MYPAGE4150", "허용되지 않은 이미지 형식입니다."), MYPAGE_IMAGE_TOO_LARGE(HttpStatus.PAYLOAD_TOO_LARGE, "MYPAGE4130", "이미지 용량이 허용 범위를 초과했습니다."), MYPAGE_IMAGE_UPLOAD_FAILED(HttpStatus.BAD_GATEWAY, "MYPAGE5020", "이미지 업로드에 실패했습니다."), + MYPAGE_PASSWORD_NOT_SUPPORTED(HttpStatus.CONFLICT, "MYPAGE4091", "비밀번호를 변경할 수 없는 계정입니다."), // 계정 관련 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "USER4041", "사용자를 찾을 수 없습니다."),