From f15575ff53c16df04d7d48119d254f9e1730246b Mon Sep 17 00:00:00 2001 From: larama-C Date: Fri, 12 Dec 2025 17:17:48 +0900 Subject: [PATCH 01/11] =?UTF-8?q?chore:=20Redis=20=ED=8F=AC=ED=8A=B8=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95=20=EB=B0=8F=20PassWordEncoder=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/security/PassWordEncoderConfig.java | 14 ++++++++++++++ .../global/security/SecurityConfig.java | 11 +++-------- src/main/resources/application-dev.yml | 4 ++++ 3 files changed, 21 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/global/security/PassWordEncoderConfig.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/security/PassWordEncoderConfig.java b/src/main/java/com/back/web7_9_codecrete_be/global/security/PassWordEncoderConfig.java new file mode 100644 index 00000000..bc255b85 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/security/PassWordEncoderConfig.java @@ -0,0 +1,14 @@ +package com.back.web7_9_codecrete_be.global.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class PassWordEncoderConfig { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java b/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java index 90de313b..8b81e73e 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/security/SecurityConfig.java @@ -1,5 +1,6 @@ package com.back.web7_9_codecrete_be.global.security; +import com.back.web7_9_codecrete_be.domain.auth.service.TokenService; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -7,8 +8,6 @@ import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; -import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -19,6 +18,7 @@ public class SecurityConfig { private final JwtTokenProvider jwtTokenProvider; private final JwtProperties jwtProperties; private final CustomUserDetailService customUserDetailService; + private final TokenService tokenService; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -48,7 +48,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ) .addFilterBefore( - new JwtAuthenticationFilter(jwtTokenProvider, jwtProperties), + new JwtAuthenticationFilter(jwtTokenProvider, jwtProperties, tokenService), UsernamePasswordAuthenticationFilter.class ); @@ -59,9 +59,4 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception { return configuration.getAuthenticationManager(); } - - @Bean - public PasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } } diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 31dea49e..6de946d0 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -1,4 +1,8 @@ spring: + data: + redis: + host: localhost + port: 6379 datasource: url: jdbc:h2:./db_dev;MODE=MySQL username: sa From 988f03bd8d9c052473908bca63eb03dfa87c2651 Mon Sep 17 00:00:00 2001 From: larama-C Date: Fri, 12 Dec 2025 17:19:30 +0900 Subject: [PATCH 02/11] =?UTF-8?q?refactor:=20DTO=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../auth/dto/request/EmailSendRequest.java | 5 +++++ .../auth/dto/request/EmailVerifyRequest.java | 12 ++++++++++++ .../domain/auth/dto/request/LoginRequest.java | 5 +++++ .../domain/auth/dto/request/SignupRequest.java | 17 +++++++++++++++++ 4 files changed, 39 insertions(+) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/EmailSendRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/EmailSendRequest.java index 4a366d45..3d24bc9b 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/EmailSendRequest.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/EmailSendRequest.java @@ -1,8 +1,13 @@ package com.back.web7_9_codecrete_be.domain.auth.dto.request; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; import lombok.Getter; @Getter public class EmailSendRequest { + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") private String email; } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/EmailVerifyRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/EmailVerifyRequest.java index 868db939..fb8d5d78 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/EmailVerifyRequest.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/EmailVerifyRequest.java @@ -1,9 +1,21 @@ package com.back.web7_9_codecrete_be.domain.auth.dto.request; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import lombok.Getter; @Getter public class EmailVerifyRequest { + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") private String email; + + @NotBlank(message = "인증 코드는 필수입니다.") + @Pattern( + regexp = "^[A-Z0-9]{6}$", + message = "인증 코드는 영문 대문자와 숫자를 포함한 6자리여야 합니다." + ) private String code; } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/LoginRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/LoginRequest.java index 595b15a8..857f5ec3 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/LoginRequest.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/LoginRequest.java @@ -1,14 +1,19 @@ package com.back.web7_9_codecrete_be.domain.auth.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; import lombok.Getter; @Getter @Schema(description = "로그인 요청 DTO") public class LoginRequest { + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") @Schema(description = "사용자 이메일", example = "test@example.com") private String email; + @NotBlank(message = "비밀번호는 필수입니다.") @Schema(description = "비밀번호", example = "1234abcd!") private String password; } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/SignupRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/SignupRequest.java index 8db1dbbb..79f18714 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/SignupRequest.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/dto/request/SignupRequest.java @@ -1,20 +1,37 @@ package com.back.web7_9_codecrete_be.domain.auth.dto.request; import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; import lombok.Getter; @Getter @Schema(description = "회원가입 요청 DTO") public class SignupRequest { + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") @Schema(description = "사용자 이메일", example = "test@example.com") private String email; + @NotBlank(message = "닉네임은 필수입니다.") @Schema(description = "닉네임", example = "codeMaster") private String nickname; + @NotBlank(message = "비밀번호는 필수입니다.") + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*]).{8,}$", + message = "비밀번호는 영문, 숫자, 특수문자를 포함한 8자 이상이어야 합니다." + ) @Schema(description = "비밀번호", example = "1234abcd!") private String password; + @NotBlank(message = "생년월일은 필수입니다.") + @Pattern( + regexp = "\\d{4}-\\d{2}-\\d{2}", + message = "생년월일은 yyyy-MM-dd 형식이어야 합니다." + ) @Schema(description = "생년월일", example = "2000-08-25") private String birth; From ec6443dbb767d6dc8225fd2735f590c4c1f5d9fd Mon Sep 17 00:00:00 2001 From: larama-C Date: Fri, 12 Dec 2025 17:19:58 +0900 Subject: [PATCH 03/11] =?UTF-8?q?refactor:=20DTO=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/controller/AuthController.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/controller/AuthController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/controller/AuthController.java index f7fccd71..c7f40e15 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/controller/AuthController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/controller/AuthController.java @@ -13,6 +13,7 @@ import com.back.web7_9_codecrete_be.global.rsData.RsData; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -27,14 +28,14 @@ public class AuthController { @Operation(summary = "회원가입", description = "사용자 이메일, 비밀번호, 닉네임, 생년월일을 이용하여 회원가입을 진행합니다.") @PostMapping("/signup") - public RsData signUp(@RequestBody SignupRequest req) { + public RsData signUp(@Valid @RequestBody SignupRequest req) { authService.signUp(req); return RsData.success("회원가입이 완료되었습니다."); } @Operation(summary = "로그인", description = "이메일/비밀번호로 로그인합니다. 성공 시 사용자 닉네임을 반환합니다.") @PostMapping("/login") - public RsData login(@RequestBody LoginRequest req) { + public RsData login(@Valid @RequestBody LoginRequest req) { LoginResponse response = authService.login(req); return RsData.success("로그인 성공", response); } @@ -49,14 +50,14 @@ public RsData logout() { @Operation(summary = "이메일 인증코드 전송", description = "입력된 이메일로 인증코드를 전송합니다.") @PostMapping("/email/send") - public RsData sendVerificationCode(@RequestBody EmailSendRequest req) { + public RsData sendVerificationCode(@Valid @RequestBody EmailSendRequest req) { authService.sendVerificationCode(req.getEmail()); return RsData.success("인증코드가 발송되었습니다."); } @Operation(summary = "이메일 인증코드 검증", description = "사용자가 입력한 인증코드가 맞는지 확인합니다.") @PostMapping("/email/verify") - public RsData verifyEmailCode(@RequestBody EmailVerifyRequest req) { + public RsData verifyEmailCode(@Valid @RequestBody EmailVerifyRequest req) { authService.verifyEmailCode(req.getEmail(), req.getCode()); return RsData.success("이메일 인증이 완료되었습니다."); } @@ -70,7 +71,7 @@ public RsData checkNickname(@RequestParam String nickname) { @Operation(summary = "임시 비밀번호 재발급", description = "특정 이메일로 임시 비밀번호를 발송합니다.") @PostMapping("/password/reset") - public RsData resetPassword(@RequestBody EmailSendRequest req) { + public RsData resetPassword(@Valid @RequestBody EmailSendRequest req) { authService.resetPassword(req.getEmail()); return RsData.success("임시 비밀번호가 이메일로 발송되었습니다."); } From 895e267f2fcaef5c109ac495f305aaa125fc5983 Mon Sep 17 00:00:00 2001 From: larama-C Date: Fri, 12 Dec 2025 17:21:41 +0900 Subject: [PATCH 04/11] =?UTF-8?q?refactor:=20Validation=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=20=EC=A0=84=EC=97=AD=20=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../error/handler/GlobalExceptionHandler.java | 29 +++++++++++++++---- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/error/handler/GlobalExceptionHandler.java b/src/main/java/com/back/web7_9_codecrete_be/global/error/handler/GlobalExceptionHandler.java index 4530109f..3d38f907 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/error/handler/GlobalExceptionHandler.java @@ -1,14 +1,13 @@ package com.back.web7_9_codecrete_be.global.error.handler; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ExceptionHandler; -import org.springframework.web.bind.annotation.RestControllerAdvice; - import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import com.back.web7_9_codecrete_be.global.error.util.ErrorResponse; import com.back.web7_9_codecrete_be.global.rsData.RsData; - import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; @Slf4j @RestControllerAdvice @@ -20,4 +19,24 @@ public ResponseEntity> handleBusinessException(BusinessException ex return ErrorResponse.build(ex.getErrorCode()); } + // @Valid 검증 실패 처리 + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidationException( + MethodArgumentNotValidException ex + ) { + String message = ex.getBindingResult() + .getFieldErrors() + .stream() + .findFirst() + .map(error -> error.getDefaultMessage()) + .orElse("입력값이 올바르지 않습니다."); + + return ResponseEntity + .badRequest() + .body(new RsData<>( + 400, + "VALIDATION_ERROR", + message + )); + } } From bef88eb34c7b14b088959be56636425b26a3d4e0 Mon Sep 17 00:00:00 2001 From: larama-C Date: Fri, 12 Dec 2025 17:23:07 +0900 Subject: [PATCH 05/11] =?UTF-8?q?feat:=20=EB=A6=AC=ED=94=84=EB=A0=88?= =?UTF-8?q?=EC=89=AC=20=ED=86=A0=ED=81=B0,=20=EC=9D=B8=EC=A6=9D=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20Redis=EB=A1=9C=20=EC=9D=B4=EA=B4=80=20?= =?UTF-8?q?=EB=B0=8F=20=ED=86=A0=ED=81=B0=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/entity/RefreshToken.java | 13 +++++ .../RefreshTokenRedisRepository.java | 37 +++++++++++++ .../repository/RefreshTokenRepository.java | 9 ---- .../domain/auth/service/AuthService.java | 2 + .../domain/auth/service/TokenService.java | 25 ++++++++- .../VerificationCodeRedisRepository.java | 37 +++++++++++++ .../repository/VerifiedEmailRepository.java | 1 + .../domain/email/service/EmailService.java | 38 ++++++------- .../global/config/RedisConfig.java | 49 +++++++++++++++++ .../security/JwtAuthenticationFilter.java | 54 ++++++++++++++----- 10 files changed, 220 insertions(+), 45 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/auth/entity/RefreshToken.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/auth/repository/RefreshTokenRedisRepository.java delete mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/auth/repository/RefreshTokenRepository.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerificationCodeRedisRepository.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/global/config/RedisConfig.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/entity/RefreshToken.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/entity/RefreshToken.java new file mode 100644 index 00000000..bf2b6a8f --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/entity/RefreshToken.java @@ -0,0 +1,13 @@ +package com.back.web7_9_codecrete_be.domain.auth.entity; + +import lombok.AllArgsConstructor; +import lombok.Getter; + +@Getter +@AllArgsConstructor +public class RefreshToken { + + private Long userId; // 사용자 ID + private String refreshToken; // 실제 토큰 값 + private long expiration; // TTL (초 단위) +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/repository/RefreshTokenRedisRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/repository/RefreshTokenRedisRepository.java new file mode 100644 index 00000000..a87db942 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/repository/RefreshTokenRedisRepository.java @@ -0,0 +1,37 @@ +package com.back.web7_9_codecrete_be.domain.auth.repository; + +import com.back.web7_9_codecrete_be.domain.auth.entity.RefreshToken; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class RefreshTokenRedisRepository { + private final RedisTemplate redisTemplate; + + private static final String REFRESH_TOKEN_PREFIX = "refreshToken: "; + + public void save(RefreshToken refreshToken) { + redisTemplate.opsForValue().set( + REFRESH_TOKEN_PREFIX + refreshToken.getUserId(), + refreshToken.getRefreshToken(), + refreshToken.getExpiration(), + TimeUnit.SECONDS + ); + } + + public String findByUserId(Long userId) { + return (String) redisTemplate.opsForValue().get(REFRESH_TOKEN_PREFIX + userId); + } + + public void deleteByUserId(Long userId){ + redisTemplate.delete(REFRESH_TOKEN_PREFIX + userId); + } + + public boolean existsByUserId(Long userId) { + return redisTemplate.hasKey(REFRESH_TOKEN_PREFIX + userId); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/repository/RefreshTokenRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/repository/RefreshTokenRepository.java deleted file mode 100644 index 23d5525c..00000000 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/repository/RefreshTokenRepository.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.back.web7_9_codecrete_be.domain.auth.repository; - -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -@Repository -@RequiredArgsConstructor -public class RefreshTokenRepository { -} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/AuthService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/AuthService.java index 60d31420..11807b41 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/AuthService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/AuthService.java @@ -50,6 +50,8 @@ public void signUp(SignupRequest req) { .build(); userRepository.save(user); + + emailService.clearVerifiedEmail(req.getEmail()); } // 로그인 diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/TokenService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/TokenService.java index a0c1a2d2..3b0e65f0 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/TokenService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/TokenService.java @@ -1,6 +1,9 @@ package com.back.web7_9_codecrete_be.domain.auth.service; +import com.back.web7_9_codecrete_be.domain.auth.entity.RefreshToken; +import com.back.web7_9_codecrete_be.domain.auth.repository.RefreshTokenRedisRepository; import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import com.back.web7_9_codecrete_be.global.rq.Rq; @@ -16,6 +19,8 @@ public class TokenService { private final JwtTokenProvider jwtTokenProvider; private final JwtProperties jwtProperties; private final Rq rq; + private final UserRepository userRepository; + private final RefreshTokenRedisRepository refreshTokenRedisRepository; // 로그인 시 실행 시 쿠키에 토큰 발급 public void issueTokens(User user) { @@ -24,13 +29,21 @@ public void issueTokens(User user) { rq.setCookie("ACCESS_TOKEN", access, (int) jwtProperties.getAccessTokenExpiration()); rq.setCookie("REFRESH_TOKEN", refresh, (int) jwtProperties.getRefreshTokenExpiration()); + + refreshTokenRedisRepository.save( + new RefreshToken( + user.getId(), + refresh, + jwtProperties.getRefreshTokenExpiration() + ) + ); } // 로그아웃 시 실행 시 쿠키 삭제 public void removeTokens(User user) { rq.removeCookie("ACCESS_TOKEN"); rq.removeCookie("REFRESH_TOKEN"); - + refreshTokenRedisRepository.deleteByUserId(user.getId()); } public String reissueAccessToken() { @@ -47,6 +60,16 @@ public String reissueAccessToken() { // RefreshToken에서 email(Subject) 추출 String email = jwtTokenProvider.getEmailFromToken(refresh); + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new BusinessException(AuthErrorCode.USER_NOT_FOUND)); + + String savedRefresh = + refreshTokenRedisRepository.findByUserId(user.getId()); + + if (savedRefresh == null || !savedRefresh.equals(refresh)) { + throw new BusinessException(AuthErrorCode.INVALID_TOKEN); + } + // AccessToken 재발급 String newAccess = jwtTokenProvider.generateAccessToken(email); diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerificationCodeRedisRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerificationCodeRedisRepository.java new file mode 100644 index 00000000..704bb001 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerificationCodeRedisRepository.java @@ -0,0 +1,37 @@ +package com.back.web7_9_codecrete_be.domain.email.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class VerificationCodeRedisRepository { + + private final RedisTemplate redisTemplate; + + private static final String PREFIX = "email:verify:"; + + public void save(String email, String code, long ttlSeconds) { + redisTemplate.opsForValue().set( + PREFIX + email, + code, + ttlSeconds, + TimeUnit.SECONDS + ); + } + + public String findByEmail(String email) { + return redisTemplate.opsForValue().get(PREFIX + email); + } + + public void deleteByEmail(String email) { + redisTemplate.delete(PREFIX + email); + } + + public boolean existsByEmail(String email) { + return redisTemplate.hasKey(PREFIX + email); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerifiedEmailRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerifiedEmailRepository.java index 18df1c7d..9ace1064 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerifiedEmailRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerifiedEmailRepository.java @@ -5,4 +5,5 @@ public interface VerifiedEmailRepository extends JpaRepository { boolean existsByEmail(String email); + void deleteByEmail(String email); } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java index 1faba502..27af65b8 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java @@ -1,8 +1,7 @@ package com.back.web7_9_codecrete_be.domain.email.service; -import com.back.web7_9_codecrete_be.domain.email.entity.VerificationCode; import com.back.web7_9_codecrete_be.domain.email.entity.VerifiedEmail; -import com.back.web7_9_codecrete_be.domain.email.repository.VerificationCodeRepository; +import com.back.web7_9_codecrete_be.domain.email.repository.VerificationCodeRedisRepository; import com.back.web7_9_codecrete_be.domain.email.repository.VerifiedEmailRepository; import com.back.web7_9_codecrete_be.global.error.code.MailErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; @@ -17,14 +16,13 @@ import org.springframework.web.reactive.function.client.WebClient; import java.security.SecureRandom; -import java.time.LocalDateTime; @Slf4j @Service @RequiredArgsConstructor public class EmailService { - private final VerificationCodeRepository verificationCodeRepository; + private final VerificationCodeRedisRepository verificationCodeRedisRepository; private final VerifiedEmailRepository verifiedEmailRepository; private final WebClient mailgunClient; @@ -66,16 +64,10 @@ public void createAndSendVerificationCode(String email) { String code = generateVerificationCode(); // 기존 코드 있으면 삭제 - verificationCodeRepository.deleteByEmail(email); + verificationCodeRedisRepository.deleteByEmail(email); - // DB 저장 - VerificationCode entity = VerificationCode.builder() - .email(email) - .code(code) - .expireAt(LocalDateTime.now().plusSeconds(TTL_SECONDS)) - .build(); - - verificationCodeRepository.save(entity); + // Redis 저장 (TTL 5분) + verificationCodeRedisRepository.save(email, code, TTL_SECONDS); String content = """ 안녕하세요. NCB 입니다. @@ -103,23 +95,20 @@ private String generateVerificationCode() { // 인증코드 검증 @Transactional public void verifyCode(String email, String inputCode) { - VerificationCode saved = verificationCodeRepository.findByEmail(email) - .orElseThrow(() -> new BusinessException(MailErrorCode.VERIFICATION_CODE_EXPIRED)); + String savedCode = verificationCodeRedisRepository.findByEmail(email); - if (saved.isExpired()) { - verificationCodeRepository.deleteByEmail(email); + if (savedCode == null) { throw new BusinessException(MailErrorCode.VERIFICATION_CODE_EXPIRED); } - if (!saved.getCode().equals(inputCode)) { + if (!savedCode.equals(inputCode)) { throw new BusinessException(MailErrorCode.VERIFICATION_CODE_MISMATCH); } - // 성공 시 삭제 - verificationCodeRepository.deleteByEmail(email); - + // 성공 시 Redis에서 삭제 + verificationCodeRedisRepository.deleteByEmail(email); - // 인증 완료 상태 저장 + // 인증 완료 상태 저장 (DB) verifiedEmailRepository.save(new VerifiedEmail(email)); log.info("[이메일 인증 성공] {}", email); @@ -142,4 +131,9 @@ public void sendNewPassword(String email, String newPassword) { sendEmail(email, "[NCB] 임시 비밀번호 안내", content); } + + @Transactional + public void clearVerifiedEmail(String email) { + verifiedEmailRepository.deleteByEmail(email); + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/config/RedisConfig.java b/src/main/java/com/back/web7_9_codecrete_be/global/config/RedisConfig.java new file mode 100644 index 00000000..7f3fcf8f --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/config/RedisConfig.java @@ -0,0 +1,49 @@ +package com.back.web7_9_codecrete_be.global.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + //Redis와 연결을 위한 Connection 생성 + @Bean + public RedisConnectionFactory redisConnectionFactory() { + final RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration(host,port); + return new LettuceConnectionFactory(configuration); + } + + //문자열만 사용할 때(refreshToken) + @Bean + public StringRedisTemplate stringRedisTemplate() { + return new StringRedisTemplate(redisConnectionFactory()); + } + + //Json 직렬화 필요할 때(복잡한 객체 저장 등) + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate template = new RedisTemplate<>(); + + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + + template.setHashKeySerializer(new StringRedisSerializer()); + template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer()); + + template.setConnectionFactory(redisConnectionFactory()); + return template; + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/security/JwtAuthenticationFilter.java b/src/main/java/com/back/web7_9_codecrete_be/global/security/JwtAuthenticationFilter.java index 73b8fb52..46c3038b 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/security/JwtAuthenticationFilter.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/security/JwtAuthenticationFilter.java @@ -1,5 +1,7 @@ package com.back.web7_9_codecrete_be.global.security; +import com.back.web7_9_codecrete_be.domain.auth.service.TokenService; +import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -7,6 +9,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.util.StringUtils; @@ -15,11 +18,14 @@ import java.io.IOException; import java.util.Arrays; + +@Slf4j @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtTokenProvider jwtTokenProvider; private final JwtProperties jwtProperties; + private final TokenService tokenService; @Override protected void doFilterInternal(HttpServletRequest request, @@ -27,30 +33,52 @@ protected void doFilterInternal(HttpServletRequest request, FilterChain filterChain) throws ServletException, IOException { - String token = resolveToken(request); + String accessToken = resolveToken(request); - if (StringUtils.hasText(token)) { + // Access Token이 있는 경우 우선 검증 시도 + if (StringUtils.hasText(accessToken)) { try { - if (jwtTokenProvider.validateToken(token)) { - Authentication auth = jwtTokenProvider.getAuthentication(token); + if (jwtTokenProvider.validateToken(accessToken)) { + Authentication auth = + jwtTokenProvider.getAuthentication(accessToken); SecurityContextHolder.getContext().setAuthentication(auth); + filterChain.doFilter(request, response); + return; } } catch (BusinessException e) { - // 토큰 문제 → 인증 안 된 채로 계속 진행 + // Access Token 만료가 아닌 경우 재발급 안 함 + if (e.getErrorCode() != AuthErrorCode.TOKEN_EXPIRED) { + log.debug("Invalid access token: {}", e.getErrorCode()); + filterChain.doFilter(request, response); + return; + } + // TOKEN_EXPIRED 인 경우만 아래 재발급 로직으로 내려감 } } + // Access Token이 없거나 / 만료된 경우 Refresh 기반 재발급 시도 + try { + String newAccess = tokenService.reissueAccessToken(); + + Authentication auth = + jwtTokenProvider.getAuthentication(newAccess); + SecurityContextHolder.getContext().setAuthentication(auth); + + } catch (BusinessException ex) { + // Refresh도 실패 → 익명 사용자 유지 + log.debug("Access Token 재발급 실패: {}", ex.getErrorCode()); + } + filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { - if (request.getCookies() != null) { - return Arrays.stream(request.getCookies()) - .filter(c -> "ACCESS_TOKEN".equals(c.getName())) - .findFirst() - .map(Cookie::getValue) - .orElse(null); - } - return null; + if (request.getCookies() == null) return null; + + return Arrays.stream(request.getCookies()) + .filter(c -> "ACCESS_TOKEN".equals(c.getName())) + .findFirst() + .map(Cookie::getValue) + .orElse(null); } } From df7b8c066033f00317e5d9dbde72a69d08b45b2c Mon Sep 17 00:00:00 2001 From: larama-C Date: Fri, 12 Dec 2025 20:41:25 +0900 Subject: [PATCH 06/11] =?UTF-8?q?fix:=20Github=20CI=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20Redis=20=EA=B2=BD=EB=A1=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-test.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index e69de29b..3cacfbb1 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -0,0 +1,5 @@ +spring: + data: + redis: + host: localhost + port: 6379 \ No newline at end of file From c4a9ec50f4a3de27656bc121717c617860a6687a Mon Sep 17 00:00:00 2001 From: larama-C Date: Mon, 15 Dec 2025 00:04:13 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EC=88=98?= =?UTF-8?q?=EC=A0=95,=20=ED=83=88=ED=87=B4=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/controller/UserController.java | 67 +++++++++++++- .../domain/users/entity/User.java | 14 +++ .../domain/users/entity/UserStatus.java | 1 + .../domain/users/service/UserService.java | 91 +++++++++++++++++++ 4 files changed, 171 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java index daf15ef2..cff14328 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java @@ -1,13 +1,76 @@ package com.back.web7_9_codecrete_be.domain.users.controller; +import com.back.web7_9_codecrete_be.domain.auth.service.TokenService; +import com.back.web7_9_codecrete_be.domain.users.dto.request.UserUpdateNicknameRequest; +import com.back.web7_9_codecrete_be.domain.users.dto.request.UserUpdatePasswordRequest; +import com.back.web7_9_codecrete_be.domain.users.dto.response.UserResponse; +import com.back.web7_9_codecrete_be.domain.users.entity.User; import com.back.web7_9_codecrete_be.domain.users.service.UserService; +import com.back.web7_9_codecrete_be.global.rq.Rq; +import com.back.web7_9_codecrete_be.global.rsData.RsData; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; @RestController @RequestMapping("/api/v1/users") @RequiredArgsConstructor +@Tag(name = "User", description = "사용자 정보 API") public class UserController { private final UserService userService; + private final TokenService tokenService; + private final Rq rq; + + @Operation(summary = "내 정보 조회", description = "현재 로그인된 사용자의 정보를 조회합니다.") + @GetMapping("/me") + public RsData getMyInfo() { + User user = rq.getUser(); + UserResponse response = userService.getMyInfo(user); + return RsData.success("사용자 정보 조회 성공", response); + } + + @Operation(summary = "내 닉네임 수정", description = "닉네임을 수정합니다.") + @PatchMapping("/nickname") + public RsData updateNickname( + @Valid @RequestBody UserUpdateNicknameRequest req + ) { + User user = rq.getUser(); + UserResponse response = userService.updateNickname(user, req); + return RsData.success("사용자 닉네임 변경 완료", response); + } + + @Operation(summary = "내 프로필 이미지 수정", description = "프로필 이미지를 수정합니다.") + @PatchMapping("/profile-image") + public RsData updateProfileImage( + @RequestPart MultipartFile file + ) { + User user = rq.getUser(); + String imageUrl = userService.updateProfileImage(user, file); + return RsData.success("프로필 이미지가 변경되었습니다.", imageUrl); + } + + @Operation(summary = "비밀번호 변경", description = "현재 비밀번호를 확인한 후 새로운 비밀번호로 변경합니다.") + @PatchMapping("/password") + public RsData updatePassword( + @Valid @RequestBody UserUpdatePasswordRequest req + ) { + User user = rq.getUser(); + userService.updatePassword(user, req); + return RsData.success("비밀번호가 변경되었습니다."); + } + + + @Operation(summary = "회원 탈퇴", description = "현재 로그인된 사용자를 탈퇴 처리합니다.") + @DeleteMapping("/me") + public RsData deleteMyAccount() { + User user = rq.getUser(); + + userService.deleteMyAccount(user); + tokenService.removeTokens(user); // refresh/access 토큰 정리 + + return RsData.success("회원 탈퇴가 완료되었습니다."); + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java index c4f151c6..8e08a5e2 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java @@ -82,5 +82,19 @@ public User(String email, public void changePassword(String encodedPassword) { this.password = encodedPassword; } + + public void updateNickname(String nickname) { + this.nickname = nickname; + } + + public void updateProfileImage(String profileImage) { + this.profileImage = profileImage; + } + + public void softDelete() { + this.isDeleted = true; + this.status = UserStatus.DELETED; + this.deletedDate = LocalDateTime.now(); + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/UserStatus.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/UserStatus.java index a2b5a1a4..5ddff070 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/UserStatus.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/UserStatus.java @@ -2,5 +2,6 @@ public enum UserStatus { ACTIVE, // 활성 상태 + DELETED, // 삭제 상태 SUSPENDED // 정지 상태 } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java index 3dd878e4..a31ca739 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java @@ -1,10 +1,101 @@ package com.back.web7_9_codecrete_be.domain.users.service; +import com.back.web7_9_codecrete_be.domain.auth.service.TokenService; +import com.back.web7_9_codecrete_be.domain.users.dto.request.UserUpdateNicknameRequest; +import com.back.web7_9_codecrete_be.domain.users.dto.request.UserUpdatePasswordRequest; +import com.back.web7_9_codecrete_be.domain.users.dto.response.UserResponse; +import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; +import com.back.web7_9_codecrete_be.global.error.code.UserErrorCode; +import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; +import com.back.web7_9_codecrete_be.global.storage.FileStorageService; import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; @Service @RequiredArgsConstructor +@Transactional public class UserService { + private final UserRepository userRepository; + private final FileStorageService fileStorageService; + private final PasswordEncoder passwordEncoder; + private final TokenService tokenService; + + // 내 정보 조회 + @Transactional(readOnly = true) + public UserResponse getMyInfo(User user) { + validateActiveUser(user); + return UserResponse.from(user); + } + + // 닉네임 수정 + public UserResponse updateNickname(User user, UserUpdateNicknameRequest req) { + validateActiveUser(user); + String newNickname = req.getNickname(); + + // 닉네임이 변경되는 경우에만 중복 검사 + if (!newNickname.equals(user.getNickname())) { + if (userRepository.existsByNickname(newNickname)) { + throw new BusinessException(UserErrorCode.NICKNAME_DUPLICATED); + } + } + + user.updateNickname(req.getNickname()); + userRepository.save(user); + return UserResponse.from(user); + } + + // 회원 탈퇴 + public void deleteMyAccount(User user) { + validateActiveUser(user); + user.softDelete(); + userRepository.save(user); + + // 로그아웃 처리 + tokenService.removeTokens(user); + } + + // 프로필 이미지 수정 + public String updateProfileImage(User user, MultipartFile file) { + validateActiveUser(user); + + // 파일 유효성 검사 + if (file == null || file.isEmpty()) { + throw new BusinessException(UserErrorCode.INVALID_PROFILE_IMAGE); + } + + String imageUrl = fileStorageService.upload(file); + + user.updateProfileImage(imageUrl); + userRepository.save(user); + + return imageUrl; + } + + // 활성 사용자 검증 + private void validateActiveUser(User user) { + if (user.getIsDeleted()) { + throw new BusinessException(UserErrorCode.USER_DELETED); + } + } + + // 비밀번호 변경 + public void updatePassword(User user, UserUpdatePasswordRequest req) { + validateActiveUser(user); + + // 현재 비밀번호 검증 + if (!passwordEncoder.matches(req.getCurrentPassword(), user.getPassword())) { + throw new BusinessException(UserErrorCode.INVALID_PASSWORD); + } + + user.changePassword(passwordEncoder.encode(req.getNewPassword())); + userRepository.save(user); + + // 비밀번호 변경 시 로그아웃 처리 + tokenService.removeTokens(user); + } } From d37f763d9774e3a098666d76a522dcad82ef6569 Mon Sep 17 00:00:00 2001 From: larama-C Date: Mon, 15 Dec 2025 00:05:19 +0900 Subject: [PATCH 08/11] =?UTF-8?q?"feat:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=EA=B4=80=EB=A0=A8=20DTO=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request/UserUpdateNicknameRequest.java | 13 +++++++++++ .../request/UserUpdatePasswordRequest.java | 22 +++++++++++++++++++ .../users/dto/request/UserUpdateRequest.java | 4 ---- 3 files changed, 35 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdateNicknameRequest.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdatePasswordRequest.java delete mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdateRequest.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdateNicknameRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdateNicknameRequest.java new file mode 100644 index 00000000..140b98e7 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdateNicknameRequest.java @@ -0,0 +1,13 @@ +package com.back.web7_9_codecrete_be.domain.users.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; + +@Getter +public class UserUpdateNicknameRequest { + + @NotBlank(message = "닉네임은 필수입니다.") + @Size(min = 2, max = 20, message = "닉네임은 2자 이상 20자 이하입니다.") + private String nickname; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdatePasswordRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdatePasswordRequest.java new file mode 100644 index 00000000..802fc560 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdatePasswordRequest.java @@ -0,0 +1,22 @@ +package com.back.web7_9_codecrete_be.domain.users.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; + +@Getter +public class UserUpdatePasswordRequest { + + @NotBlank(message = "현재 비밀번호는 필수입니다.") + @Schema(description = "현재 비밀번호", example = "oldPassword1!") + private String currentPassword; + + @NotBlank(message = "비밀번호는 필수입니다.") + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$%^&*]).{8,}$", + message = "비밀번호는 영문, 숫자, 특수문자를 포함한 8자 이상이어야 합니다." + ) + @Schema(description = "비밀번호", example = "1234abcd!") + private String newPassword; +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdateRequest.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdateRequest.java deleted file mode 100644 index 4a898e26..00000000 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/dto/request/UserUpdateRequest.java +++ /dev/null @@ -1,4 +0,0 @@ -package com.back.web7_9_codecrete_be.domain.users.dto.request; - -public class UserUpdateRequest { -} From 1e3dd027e4b1d7dd0716421055fa5ee8a7ca8b98 Mon Sep 17 00:00:00 2001 From: larama-C Date: Mon, 15 Dec 2025 00:05:56 +0900 Subject: [PATCH 09/11] =?UTF-8?q?"feat:=20=EC=9C=A0=EC=A0=80=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=EB=B0=8F=20=EB=A6=AC=ED=8C=A9=ED=86=A0=EB=A7=81"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/error/code/AuthErrorCode.java | 4 +-- .../global/error/code/UserErrorCode.java | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/AuthErrorCode.java b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/AuthErrorCode.java index 9c1f3f19..392d43d2 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/AuthErrorCode.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/AuthErrorCode.java @@ -11,14 +11,12 @@ public enum AuthErrorCode implements ErrorCode { // 회원가입 관련 EMAIL_DUPLICATED(HttpStatus.CONFLICT, "A-100", "이미 사용중인 이메일입니다."), - NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "A-101", "이미 사용중인 닉네임입니다."), - EMAIL_NOT_VERIFIED(HttpStatus.BAD_REQUEST, "A-102","이메일 인증이 완료되지 않았습니다."), + EMAIL_NOT_VERIFIED(HttpStatus.BAD_REQUEST, "A-101","이메일 인증이 완료되지 않았습니다."), // 로그인 관련 USER_NOT_FOUND(HttpStatus.NOT_FOUND, "A-110", "존재하지 않는 이메일입니다."), INVALID_PASSWORD(HttpStatus.UNAUTHORIZED, "A-111", "비밀번호가 일치하지 않습니다."), USER_INACTIVE(HttpStatus.FORBIDDEN, "A-112", "현재 비활성화된 계정입니다."), - USER_DELETED(HttpStatus.FORBIDDEN, "A-113", "탈퇴한 사용자는 로그인할 수 없습니다."), // 권한 관련 UNAUTHORIZED_USER(HttpStatus.UNAUTHORIZED, "A-120", "로그인이 필요합니다."), diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java new file mode 100644 index 00000000..64e19fcc --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java @@ -0,0 +1,26 @@ +package com.back.web7_9_codecrete_be.global.error.code; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.http.HttpStatus; + +@Getter +@AllArgsConstructor +public enum UserErrorCode implements ErrorCode { + + // 1xx - User 상태 / 중복 + NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "U-101", "이미 사용 중인 닉네임입니다."), + USER_DELETED(HttpStatus.FORBIDDEN, "U-102", "탈퇴한 사용자입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U-103", "사용자를 찾을 수 없습니다."), + + // 3xx - 입력값 / 파일 + INVALID_PROFILE_IMAGE(HttpStatus.BAD_REQUEST, "U-301", "유효하지 않은 프로필 이미지입니다."), + + // 2xx - 인증 / 비밀번호 + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U-201", "현재 비밀번호가 일치하지 않습니다."); + + private final HttpStatus status; + private final String code; + private final String message; +} + From b879f29b7be935383a5e590b36110198049beecb Mon Sep 17 00:00:00 2001 From: larama-C Date: Mon, 15 Dec 2025 00:06:46 +0900 Subject: [PATCH 10/11] =?UTF-8?q?"feat:=20=ED=83=88=ED=87=B4=EC=97=90=20?= =?UTF-8?q?=EB=8C=80=ED=95=9C=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/auth/service/AuthService.java | 13 ++++++++++++- .../domain/auth/service/TokenService.java | 8 ++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/AuthService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/AuthService.java index 11807b41..30013ccc 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/AuthService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/AuthService.java @@ -7,16 +7,19 @@ import com.back.web7_9_codecrete_be.domain.users.entity.User; import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode; +import com.back.web7_9_codecrete_be.global.error.code.UserErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import lombok.RequiredArgsConstructor; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.security.SecureRandom; import java.time.LocalDate; @Service @RequiredArgsConstructor +@Transactional public class AuthService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; @@ -38,7 +41,7 @@ public void signUp(SignupRequest req) { // 닉네임 중복 체크 if (userRepository.existsByNickname(req.getNickname())) { - throw new BusinessException(AuthErrorCode.NICKNAME_DUPLICATED); + throw new BusinessException(UserErrorCode.NICKNAME_DUPLICATED); } User user = User.builder() @@ -59,6 +62,10 @@ public LoginResponse login(LoginRequest req) { User user = userRepository.findByEmail(req.getEmail()) .orElseThrow(() -> new BusinessException(AuthErrorCode.USER_NOT_FOUND)); + if (user.getIsDeleted()) { + throw new BusinessException(UserErrorCode.USER_DELETED); + } + if (!passwordEncoder.matches(req.getPassword(), user.getPassword())) { throw new BusinessException(AuthErrorCode.INVALID_PASSWORD); } @@ -87,6 +94,10 @@ public void resetPassword(String email) { User user = userRepository.findByEmail(email) .orElseThrow(() -> new BusinessException(AuthErrorCode.USER_NOT_FOUND)); + if (user.getIsDeleted()) { + throw new BusinessException(UserErrorCode.USER_DELETED); + } + String tempPassword = generateTempPassword(); // 비밀번호 변경 diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/TokenService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/TokenService.java index 3b0e65f0..5e6e2086 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/TokenService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/auth/service/TokenService.java @@ -5,6 +5,7 @@ import com.back.web7_9_codecrete_be.domain.users.entity.User; import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; import com.back.web7_9_codecrete_be.global.error.code.AuthErrorCode; +import com.back.web7_9_codecrete_be.global.error.code.UserErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import com.back.web7_9_codecrete_be.global.rq.Rq; import com.back.web7_9_codecrete_be.global.security.JwtProperties; @@ -63,8 +64,11 @@ public String reissueAccessToken() { User user = userRepository.findByEmail(email) .orElseThrow(() -> new BusinessException(AuthErrorCode.USER_NOT_FOUND)); - String savedRefresh = - refreshTokenRedisRepository.findByUserId(user.getId()); + if (user.getIsDeleted()) { + throw new BusinessException(UserErrorCode.USER_DELETED); + } + + String savedRefresh = refreshTokenRedisRepository.findByUserId(user.getId()); if (savedRefresh == null || !savedRefresh.equals(refresh)) { throw new BusinessException(AuthErrorCode.INVALID_TOKEN); From 84f01fb5fd8c5f512049534e7a4ebe4a300e3422 Mon Sep 17 00:00:00 2001 From: larama-C Date: Mon, 15 Dec 2025 00:07:08 +0900 Subject: [PATCH 11/11] =?UTF-8?q?"feat:=20=ED=94=84=EB=A1=9C=ED=95=84=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=9E=84=EC=8B=9C=20=ED=8C=8C=EC=9D=BC=20=EC=B6=94=EA=B0=80"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/storage/FileStorageService.java | 8 ++++++++ .../global/storage/S3FileStorageService.java | 18 ++++++++++++++++++ 2 files changed, 26 insertions(+) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/global/storage/FileStorageService.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/global/storage/S3FileStorageService.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/storage/FileStorageService.java b/src/main/java/com/back/web7_9_codecrete_be/global/storage/FileStorageService.java new file mode 100644 index 00000000..156d4ad8 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/storage/FileStorageService.java @@ -0,0 +1,8 @@ +package com.back.web7_9_codecrete_be.global.storage; + +import org.springframework.web.multipart.MultipartFile; + +public interface FileStorageService { + //임시 구현 + String upload(MultipartFile file); +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/storage/S3FileStorageService.java b/src/main/java/com/back/web7_9_codecrete_be/global/storage/S3FileStorageService.java new file mode 100644 index 00000000..023ec613 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/storage/S3FileStorageService.java @@ -0,0 +1,18 @@ +package com.back.web7_9_codecrete_be.global.storage; + +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.UUID; + +@Service +public class S3FileStorageService implements FileStorageService { + @Override + public String upload(MultipartFile file) { + + // 임시 URL 생성 + String fakeFileName = UUID.randomUUID() + "_" + file.getOriginalFilename(); + + return "https://dummy-cdn.codecrete.com/profile/" + fakeFileName; + } +}