diff --git a/build.gradle b/build.gradle index 55c06dd..f801c69 100644 --- a/build.gradle +++ b/build.gradle @@ -46,6 +46,7 @@ dependencies { // JPA & DB implementation 'org.springframework.boot:spring-boot-starter-data-jpa' runtimeOnly 'com.mysql:mysql-connector-j' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' // Lombok compileOnly 'org.projectlombok:lombok' diff --git a/src/main/java/com/mycom/socket/auth/jwt/JWTProperties.java b/src/main/java/com/mycom/socket/auth/config/JWTProperties.java similarity index 92% rename from src/main/java/com/mycom/socket/auth/jwt/JWTProperties.java rename to src/main/java/com/mycom/socket/auth/config/JWTProperties.java index ad1e8d6..f35a579 100644 --- a/src/main/java/com/mycom/socket/auth/jwt/JWTProperties.java +++ b/src/main/java/com/mycom/socket/auth/config/JWTProperties.java @@ -1,4 +1,4 @@ -package com.mycom.socket.auth.jwt; +package com.mycom.socket.auth.config; import lombok.Getter; import lombok.Setter; import org.springframework.boot.context.properties.ConfigurationProperties; diff --git a/src/main/java/com/mycom/socket/auth/config/MailProperties.java b/src/main/java/com/mycom/socket/auth/config/MailProperties.java new file mode 100644 index 0000000..0d76b70 --- /dev/null +++ b/src/main/java/com/mycom/socket/auth/config/MailProperties.java @@ -0,0 +1,22 @@ +package com.mycom.socket.auth.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "spring.mail") +public class MailProperties { + private String host; + private int port; + private String protocol; + private String username; + private String password; + private String senderEmail; + private String senderName; + private String subject; + private String bodyTemplate; +} \ No newline at end of file diff --git a/src/main/java/com/mycom/socket/auth/config/SecurityConfig.java b/src/main/java/com/mycom/socket/auth/config/SecurityConfig.java index 14bbcdf..0ecda28 100644 --- a/src/main/java/com/mycom/socket/auth/config/SecurityConfig.java +++ b/src/main/java/com/mycom/socket/auth/config/SecurityConfig.java @@ -1,7 +1,6 @@ package com.mycom.socket.auth.config; import com.mycom.socket.auth.jwt.JWTFilter; -import com.mycom.socket.auth.jwt.JWTProperties; import com.mycom.socket.auth.jwt.JWTUtil; import com.mycom.socket.auth.service.MemberDetailsService; import lombok.RequiredArgsConstructor; diff --git a/src/main/java/com/mycom/socket/auth/controller/AuthController.java b/src/main/java/com/mycom/socket/auth/controller/AuthController.java index b9c624c..bb7c70b 100644 --- a/src/main/java/com/mycom/socket/auth/controller/AuthController.java +++ b/src/main/java/com/mycom/socket/auth/controller/AuthController.java @@ -38,12 +38,12 @@ public void logout(HttpServletResponse response) { authService.logout(response); } - @PostMapping("/verification") + @PostMapping("/verify-email") public EmailVerificationResponse sendVerificationEmail(@Valid @RequestBody EmailRequest request) { return mailService.sendMail(request.email()); } - @PostMapping("/email/verify") + @PostMapping("/verification-code") public EmailVerificationResponse verifyEmail(@Valid @RequestBody EmailVerificationRequest request) { return mailService.verifyCode(request.email(), request.code()); } diff --git a/src/main/java/com/mycom/socket/auth/jwt/JWTFilter.java b/src/main/java/com/mycom/socket/auth/jwt/JWTFilter.java index f6ca338..97f27d9 100644 --- a/src/main/java/com/mycom/socket/auth/jwt/JWTFilter.java +++ b/src/main/java/com/mycom/socket/auth/jwt/JWTFilter.java @@ -1,5 +1,6 @@ package com.mycom.socket.auth.jwt; +import com.mycom.socket.auth.config.JWTProperties; import com.mycom.socket.auth.service.MemberDetailsService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; diff --git a/src/main/java/com/mycom/socket/auth/jwt/JWTUtil.java b/src/main/java/com/mycom/socket/auth/jwt/JWTUtil.java index b7e3a3b..a8b963b 100644 --- a/src/main/java/com/mycom/socket/auth/jwt/JWTUtil.java +++ b/src/main/java/com/mycom/socket/auth/jwt/JWTUtil.java @@ -1,5 +1,6 @@ package com.mycom.socket.auth.jwt; +import com.mycom.socket.auth.config.JWTProperties; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.security.Keys; import lombok.extern.slf4j.Slf4j; diff --git a/src/main/java/com/mycom/socket/auth/security/CookieUtil.java b/src/main/java/com/mycom/socket/auth/security/CookieUtil.java index 422e6c1..1826b31 100644 --- a/src/main/java/com/mycom/socket/auth/security/CookieUtil.java +++ b/src/main/java/com/mycom/socket/auth/security/CookieUtil.java @@ -1,6 +1,6 @@ package com.mycom.socket.auth.security; -import com.mycom.socket.auth.jwt.JWTProperties; +import com.mycom.socket.auth.config.JWTProperties; import jakarta.servlet.http.Cookie; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; diff --git a/src/main/java/com/mycom/socket/auth/service/MailService.java b/src/main/java/com/mycom/socket/auth/service/MailService.java index 6a2d1ec..18bfda4 100644 --- a/src/main/java/com/mycom/socket/auth/service/MailService.java +++ b/src/main/java/com/mycom/socket/auth/service/MailService.java @@ -1,43 +1,37 @@ package com.mycom.socket.auth.service; +import com.mycom.socket.auth.config.MailProperties; import com.mycom.socket.auth.dto.response.EmailVerificationResponse; -import com.mycom.socket.auth.service.data.VerificationData; import com.mycom.socket.global.exception.BaseException; +import com.mycom.socket.global.service.RedisService; import jakarta.mail.MessagingException; import jakarta.mail.internet.MimeMessage; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; import java.security.SecureRandom; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; @Service @RequiredArgsConstructor public class MailService { private final JavaMailSender javaMailSender; - private final RateLimiter rateLimiter; // 인증 번호 요청 제한 - - private final Map verificationDataMap = new ConcurrentHashMap<>(); - - @Value("${spring.mail.username}") - private String senderEmail; + private final RedisService redisService; + private final MailProperties mailProperties; /** - * 6자리 난수 인증번호 생성 - * SecureRandom 사용하여 보안성 향상 - * @return 100000~999999 범위의 인증번호 + * 6자리 인증번호 생성 (100000-999999) */ private String createVerificationCode() { // Math.random()은 예측 가능한 난수를 생성할 수 있어 보안에 취약 // SecureRandom은 암호학적으로 안전한 난수를 생성하므로 인증번호 생성에 더 적합 - SecureRandom secureRandom = new SecureRandom(); - return String.format("%06d", secureRandom.nextInt(1000000)); + return String.format("%06d", new SecureRandom().nextInt(1000000)); + } + + public boolean isEmailVerified(String email) { + return redisService.isEmailVerified(email); } /** @@ -48,14 +42,10 @@ private String createVerificationCode() { public MimeMessage createMail(String email, String verificationCode) { MimeMessage message = javaMailSender.createMimeMessage(); try { - message.setFrom(senderEmail); + message.setFrom(mailProperties.getSenderEmail()); message.setRecipients(MimeMessage.RecipientType.TO, email); message.setSubject("이메일 인증"); - String body = String.format(""" -

요청하신 인증 번호입니다.

-

%s

-

감사합니다.

- """, verificationCode); + String body = String.format(mailProperties.getBodyTemplate(), verificationCode); message.setText(body, "UTF-8", "html"); } catch (MessagingException e) { throw new BaseException("이메일 생성 중 오류가 발생했습니다: " + e.getMessage(), @@ -70,15 +60,18 @@ public MimeMessage createMail(String email, String verificationCode) { * @return 생성된 인증번호 */ public EmailVerificationResponse sendMail(String email) { - rateLimiter.checkRateLimit(email); + if (redisService.incrementCount(email) > 3) { + throw new BaseException("너무 많은 요청입니다. 1분 후에 다시 시도해주세요.", + HttpStatus.TOO_MANY_REQUESTS); + } String verificationCode = createVerificationCode(); - verificationDataMap.put(email, new VerificationData(verificationCode)); + redisService.saveCode(email, verificationCode); MimeMessage message = createMail(email, verificationCode); try { javaMailSender.send(message); - return EmailVerificationResponse.of("이메일 전송 성공"); + return EmailVerificationResponse.of("이메일 전송 성공"); // 메시지 수정 } catch (Exception e) { throw new BaseException("이메일 발송 중 오류가 발생했습니다: " + e.getMessage(), HttpStatus.INTERNAL_SERVER_ERROR); @@ -93,30 +86,19 @@ public EmailVerificationResponse sendMail(String email) { * @return 인증번호 일치 여부 */ public EmailVerificationResponse verifyCode(String email, String code) { - validateVerificationCode(code); - - VerificationData data = verificationDataMap.get(email); - if (data == null || data.isExpired()) { - throw new BaseException("인증 코드가 만료되었거나 존재하지 않습니다.", HttpStatus.BAD_REQUEST); + if (!code.matches("\\d{6}")) { + throw new BaseException("유효하지 않은 인증 코드 형식입니다.", HttpStatus.BAD_REQUEST); } - if (!data.code().equals(code)) { + try { + String saveCode = redisService.getCode(code); // 인증코드 검증 + if(!saveCode.equals(code)) { + throw new BaseException("인증 코드가 일치하지 않습니다.", HttpStatus.BAD_REQUEST); + } + return EmailVerificationResponse.of("이메일 인증이 완료되었습니다."); + } catch (Exception e) { throw new BaseException("인증 코드가 일치하지 않습니다.", HttpStatus.BAD_REQUEST); } - - verificationDataMap.put(email, data.withVerified()); - return EmailVerificationResponse.of("이메일 인증이 완료되었습니다."); - } - - private void validateVerificationCode(String code) { - if (!StringUtils.hasText(code) || !code.matches("\\d{6}")) { - throw new BaseException("유효하지 않은 인증 코드 형식입니다.", HttpStatus.BAD_REQUEST); - } - } - - public boolean isEmailVerified(String email) { - VerificationData data = verificationDataMap.get(email); - return data != null && !data.isExpired() && data.verified(); } } diff --git a/src/main/java/com/mycom/socket/auth/service/RateLimiter.java b/src/main/java/com/mycom/socket/auth/service/RateLimiter.java deleted file mode 100644 index 9864a48..0000000 --- a/src/main/java/com/mycom/socket/auth/service/RateLimiter.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.mycom.socket.auth.service; - -import com.mycom.socket.global.exception.BaseException; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -import java.time.Duration; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -@Component -@RequiredArgsConstructor -public class RateLimiter { - private final Map> requestMap = new ConcurrentHashMap<>(); - private static final int MAX_REQUESTS = 3; // 1분당 최대 3번 - private static final Duration WINDOW_SIZE = Duration.ofMinutes(1); // 1분의 시간 간격 - - @Scheduled(fixedRate = 3600000) // 1시간마다 실행 - public void cleanup() { - LocalDateTime threshold = LocalDateTime.now().minus(WINDOW_SIZE); - requestMap.entrySet().removeIf(entry -> - entry.getValue().stream().allMatch(time -> time.isBefore(threshold))); - } - - public void checkRateLimit(String email) { - List requests = requestMap.computeIfAbsent(email, k -> new ArrayList<>()); - LocalDateTime now = LocalDateTime.now(); - - requests.removeIf(requestTime -> - requestTime.plus(WINDOW_SIZE).isBefore(now)); - - if (requests.size() >= MAX_REQUESTS) { - LocalDateTime oldestRequest = requests.get(0); - Duration waitTime = WINDOW_SIZE.minus(Duration.between(oldestRequest, now)); - throw new BaseException( - String.format("너무 많은 요청입니다. %d초 후에 다시 시도해주세요.",waitTime.getSeconds()), - HttpStatus.TOO_MANY_REQUESTS); - } - - requests.add(now); - } -} diff --git a/src/main/java/com/mycom/socket/global/config/RedisConfig.java b/src/main/java/com/mycom/socket/global/config/RedisConfig.java new file mode 100644 index 0000000..a4ca4a3 --- /dev/null +++ b/src/main/java/com/mycom/socket/global/config/RedisConfig.java @@ -0,0 +1,43 @@ +package com.mycom.socket.global.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisStandaloneConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceClientConfiguration; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.repository.configuration.EnableRedisRepositories; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +@Configuration +@RequiredArgsConstructor +@EnableRedisRepositories +public class RedisConfig { + private final RedisProperties redisProperties; + + @Bean + public LettuceConnectionFactory redisConnectionFactory() { + LettuceClientConfiguration clientConfig = LettuceClientConfiguration.builder() + .commandTimeout(Duration.ofSeconds(1)) + .build(); + RedisStandaloneConfiguration serverConfig = new RedisStandaloneConfiguration( + redisProperties.getHost(), + redisProperties.getPort() + ); + return new LettuceConnectionFactory(serverConfig, clientConfig); + } + + @Bean + public RedisTemplate redisTemplate() { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(redisConnectionFactory()); + + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new StringRedisSerializer()); + return redisTemplate; + } + +} diff --git a/src/main/java/com/mycom/socket/global/config/RedisProperties.java b/src/main/java/com/mycom/socket/global/config/RedisProperties.java new file mode 100644 index 0000000..5add8bb --- /dev/null +++ b/src/main/java/com/mycom/socket/global/config/RedisProperties.java @@ -0,0 +1,16 @@ +package com.mycom.socket.global.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "spring.data.redis") +public class RedisProperties { + + private String host; + private int port; +} diff --git a/src/main/java/com/mycom/socket/global/service/RedisService.java b/src/main/java/com/mycom/socket/global/service/RedisService.java new file mode 100644 index 0000000..09e8f9e --- /dev/null +++ b/src/main/java/com/mycom/socket/global/service/RedisService.java @@ -0,0 +1,96 @@ +package com.mycom.socket.global.service; + +import com.mycom.socket.global.exception.BaseException; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.connection.RedisConnection; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Service; + +import java.time.Duration; + +@Service +@RequiredArgsConstructor +public class RedisService { + private final RedisTemplate redisTemplate; + + /** + * Redis Key Prefix 상수 + */ + private static final String VERIFIED_EMAIL_PREFIX = "verified:email:"; + private static final String VERIFICATION_CODE_PREFIX = "verification:code:"; + private static final String RATE_LIMIT_PREFIX = "rate-limit:"; + + /** + * Redis TTL 상수 + */ + private static final Duration VERIFICATION_TTL = Duration.ofMinutes(3); // 인증번호 유효시간 + private static final Duration VERIFIED_EMAIL_TTL = Duration.ofMinutes(30); // 인증된 이메일 유효시간 + private static final Duration RATE_LIMIT_TTL = Duration.ofMinutes(1); // 요청 제한 시간 + + + /** + * 인증번호를 Redis에 저장 + * Key와 Value로 동일한 인증번호를 사용 + * 3분 후 자동 삭제 + */ + public void saveCode(String email,String code) { + redisTemplate.opsForValue().set(VERIFICATION_CODE_PREFIX +email, code, VERIFICATION_TTL); + } + + /** + * Redis에서 인증번호 조회 + * 인증번호가 존재하지 않거나 만료된 경우 예외 발생 + * @throws BaseException 인증번호가 만료되었거나 존재하지 않는 경우 + */ + public String getCode(String email) { + Object savedCode = redisTemplate.opsForValue().get(VERIFICATION_CODE_PREFIX + email); + if (savedCode == null) { + throw new BaseException("인증 코드가 만료되었거나 존재하지 않습니다.", HttpStatus.BAD_REQUEST); + } + return savedCode.toString(); + } + + /** + * 이메일별 요청 횟수 증가 (Rate Limiting) + * 첫 요청시 1분 후 자동 삭제되도록 설정 + * @return 현재 요청 횟수 + */ + public Long incrementCount(String email) { + String key = RATE_LIMIT_PREFIX + email; + // redisTemplate.execute를 사용하여 Redis 명령어를 트랜잭션으로 실행 + return redisTemplate.execute( + (RedisConnection connection) -> { + // Redis의 INCR 명령어를 실행하여 값을 증가시키고 반환 + Long count = connection.incr(key.getBytes()); + if (count == 1) { + // 처음 호출된 경우, 만료 시간 설정 + connection.expire(key.getBytes(), RATE_LIMIT_TTL.getSeconds()); + } + return count; + } + ); + } + + /** + * 인증된 이메일 정보 저장 + * 30분 동안 유효 + */ + public void saveVerifiedEmail(String email) { + redisTemplate.opsForValue().set( + VERIFIED_EMAIL_PREFIX + email, + "true", + VERIFIED_EMAIL_TTL + ); + } + + /** + * 이메일 인증 여부 확인 + * @return 이메일이 인증되었으면 true, 아니면 false + */ + public boolean isEmailVerified(String email) { + Object verified = redisTemplate.opsForValue().get(VERIFIED_EMAIL_PREFIX + email); + return "true".equals(verified); + } + +} diff --git a/src/test/java/com/mycom/socket/member/controller/AuthControllerTest.java b/src/test/java/com/mycom/socket/member/controller/AuthControllerTest.java index 2452fd3..b964102 100644 --- a/src/test/java/com/mycom/socket/member/controller/AuthControllerTest.java +++ b/src/test/java/com/mycom/socket/member/controller/AuthControllerTest.java @@ -5,7 +5,7 @@ import com.mycom.socket.auth.controller.AuthController; import com.mycom.socket.auth.dto.request.RegisterRequest; import com.mycom.socket.auth.dto.response.RegisterResponse; -import com.mycom.socket.auth.jwt.JWTProperties; +import com.mycom.socket.auth.config.JWTProperties; import com.mycom.socket.auth.jwt.JWTUtil; import com.mycom.socket.auth.service.AuthService; import com.mycom.socket.auth.service.MailService; diff --git a/src/test/java/com/mycom/socket/member/service/LoginIntegrationTest.java b/src/test/java/com/mycom/socket/member/service/LoginIntegrationTest.java index 86f7f46..bcff139 100644 --- a/src/test/java/com/mycom/socket/member/service/LoginIntegrationTest.java +++ b/src/test/java/com/mycom/socket/member/service/LoginIntegrationTest.java @@ -2,7 +2,7 @@ import com.mycom.socket.auth.dto.request.LoginRequest; import com.mycom.socket.auth.dto.response.LoginResponse; -import com.mycom.socket.auth.jwt.JWTProperties; +import com.mycom.socket.auth.config.JWTProperties; import com.mycom.socket.auth.service.AuthService; import com.mycom.socket.go_socket.entity.Member; import com.mycom.socket.go_socket.entity.enums.MemberRole;