From f15575ff53c16df04d7d48119d254f9e1730246b Mon Sep 17 00:00:00 2001 From: larama-C Date: Fri, 12 Dec 2025 17:17:48 +0900 Subject: [PATCH 1/6] =?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 2/6] =?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 3/6] =?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 4/6] =?UTF-8?q?refactor:=20Validation=20=EC=98=88=EC=99=B8?= =?UTF-8?q?=20=EC=A0=84=EC=97=AD=20=EC=B2=98=EB=A6=AC=20=EC=B6=94=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 5/6] =?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 6/6] =?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