diff --git a/build.gradle b/build.gradle index 8f44e7d6..bad173c4 100644 --- a/build.gradle +++ b/build.gradle @@ -28,9 +28,14 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' + implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' + implementation 'org.springframework.boot:spring-boot-starter-aop' + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9' implementation 'io.jsonwebtoken:jjwt:0.12.6' + implementation 'com.resend:resend-java:4.1.1' compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' annotationProcessor 'org.projectlombok:lombok' diff --git a/docker-compose.local.yml b/docker-compose.local.yml new file mode 100644 index 00000000..c52ec783 --- /dev/null +++ b/docker-compose.local.yml @@ -0,0 +1,17 @@ +services: + flipnote-mysql: + image: mysql:latest + container_name: flipnote-mysql + ports: + - "3306:3306" + environment: + MYSQL_USERNAME: root + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: flipnote + TZ: Asia/Seoul + + flipnote-redis: + image: redis:latest + container_name: flipnote-redis + ports: + - "6379:6379" diff --git a/src/main/java/project/flipnote/auth/constants/AuthRedisKey.java b/src/main/java/project/flipnote/auth/constants/AuthRedisKey.java new file mode 100644 index 00000000..ad91fddb --- /dev/null +++ b/src/main/java/project/flipnote/auth/constants/AuthRedisKey.java @@ -0,0 +1,15 @@ +package project.flipnote.auth.constants; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import project.flipnote.common.constants.RedisKeys; + +@Getter +@AllArgsConstructor +public enum AuthRedisKey implements RedisKeys { + EMAIL_CODE("auth:email:code:%s", VerificationConstants.CODE_TTL_MINUTES * 60), + EMAIL_VERIFIED("auth:email:verified:%s", 600); + + private final String pattern; + private final int ttlSeconds; +} diff --git a/src/main/java/project/flipnote/auth/constants/VerificationConstants.java b/src/main/java/project/flipnote/auth/constants/VerificationConstants.java new file mode 100644 index 00000000..ad5219ad --- /dev/null +++ b/src/main/java/project/flipnote/auth/constants/VerificationConstants.java @@ -0,0 +1,10 @@ +package project.flipnote.auth.constants; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class VerificationConstants { + public static final int CODE_LENGTH = 6; + public static final int CODE_TTL_MINUTES = 5; +} diff --git a/src/main/java/project/flipnote/auth/controller/AuthController.java b/src/main/java/project/flipnote/auth/controller/AuthController.java index 9a879c05..ff93aeb2 100644 --- a/src/main/java/project/flipnote/auth/controller/AuthController.java +++ b/src/main/java/project/flipnote/auth/controller/AuthController.java @@ -9,6 +9,8 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import project.flipnote.auth.model.EmailVerificationConfirmRequest; +import project.flipnote.auth.model.EmailVerificationRequest; import project.flipnote.auth.model.TokenPair; import project.flipnote.auth.model.UserLoginDto; import project.flipnote.auth.service.AuthService; @@ -40,4 +42,20 @@ public ResponseEntity logout(HttpServletResponse servletResponse) { return ResponseEntity.ok().build(); } + + @PostMapping("/email") + public ResponseEntity sendEmailVerificationCode(@Valid @RequestBody EmailVerificationRequest req) { + authService.sendEmailVerificationCode(req); + + return ResponseEntity.ok().build(); + } + + @PostMapping("/email/confirm") + public ResponseEntity confirmEmailVerificationCode( + @Valid @RequestBody EmailVerificationConfirmRequest req + ) { + authService.confirmEmailVerificationCode(req); + + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/project/flipnote/auth/event/EmailVerificationSendEvent.java b/src/main/java/project/flipnote/auth/event/EmailVerificationSendEvent.java new file mode 100644 index 00000000..cdccde4e --- /dev/null +++ b/src/main/java/project/flipnote/auth/event/EmailVerificationSendEvent.java @@ -0,0 +1,7 @@ +package project.flipnote.auth.event; + +public record EmailVerificationSendEvent( + String to, + String code +) { +} diff --git a/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java b/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java index 34dcefda..dd351fd0 100644 --- a/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java +++ b/src/main/java/project/flipnote/auth/exception/AuthErrorCode.java @@ -10,7 +10,12 @@ @RequiredArgsConstructor public enum AuthErrorCode implements ErrorCode { - INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH_001", "이메일 또는 비밀번호가 올바르지 않습니다."); + INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH_001", "이메일 또는 비밀번호가 올바르지 않습니다."), + ALREADY_ISSUED_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "AUTH_002", "이미 발급된 인증번호가 있습니다. 잠시 후 다시 시도해 주세요."), + NOT_ISSUED_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "AUTH_003", "발급된 인증번호가 없습니다. 인증번호를 먼저 요청해 주세요."), + INVALID_VERIFICATION_CODE(HttpStatus.FORBIDDEN, "AUTH_004", "잘못된 인증번호입니다. 입력한 인증번호를 확인해 주세요."), + UNVERIFIED_EMAIL(HttpStatus.FORBIDDEN, "AUTH_005", "인증되지 않은 이메일입니다. 이메일 인증을 완료해 주세요."), + EXISTING_EMAIL(HttpStatus.CONFLICT, "AUTH_006", "이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/project/flipnote/auth/listener/EmailVerificationEventListener.java b/src/main/java/project/flipnote/auth/listener/EmailVerificationEventListener.java new file mode 100644 index 00000000..34572f19 --- /dev/null +++ b/src/main/java/project/flipnote/auth/listener/EmailVerificationEventListener.java @@ -0,0 +1,39 @@ +package project.flipnote.auth.listener; + +import org.springframework.context.event.EventListener; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.auth.constants.VerificationConstants; +import project.flipnote.auth.event.EmailVerificationSendEvent; +import project.flipnote.common.exception.EmailSendException; +import project.flipnote.infra.email.EmailService; + +@Slf4j +@RequiredArgsConstructor +@Component +public class EmailVerificationEventListener { + + private final EmailService emailService; + + @Async + @Retryable( + maxAttempts = 3, + retryFor = { EmailSendException.class }, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @EventListener + public void handleEmailVerificationSendEvent(EmailVerificationSendEvent event) { + emailService.sendEmailVerificationCode(event.to(), event.code(), VerificationConstants.CODE_TTL_MINUTES); + } + + @Recover + public void recover(EmailSendException ex, EmailVerificationSendEvent event) { + log.error("이메일 인증번호 전송 3회 실패: to={}", event.to(), ex); + } +} diff --git a/src/main/java/project/flipnote/auth/model/EmailVerificationConfirmRequest.java b/src/main/java/project/flipnote/auth/model/EmailVerificationConfirmRequest.java new file mode 100644 index 00000000..0e05b1e6 --- /dev/null +++ b/src/main/java/project/flipnote/auth/model/EmailVerificationConfirmRequest.java @@ -0,0 +1,17 @@ +package project.flipnote.auth.model; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import project.flipnote.auth.constants.VerificationConstants; + +public record EmailVerificationConfirmRequest( + + @Email @NotBlank + String email, + + @NotBlank + @Size(min = VerificationConstants.CODE_LENGTH, max = VerificationConstants.CODE_LENGTH) + String code +) { +} diff --git a/src/main/java/project/flipnote/auth/model/EmailVerificationRequest.java b/src/main/java/project/flipnote/auth/model/EmailVerificationRequest.java new file mode 100644 index 00000000..0364de35 --- /dev/null +++ b/src/main/java/project/flipnote/auth/model/EmailVerificationRequest.java @@ -0,0 +1,11 @@ +package project.flipnote.auth.model; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record EmailVerificationRequest( + + @Email @NotBlank + String email +) { +} diff --git a/src/main/java/project/flipnote/auth/model/UserLoginDto.java b/src/main/java/project/flipnote/auth/model/UserLoginDto.java index ea8dc2b7..a17a13cd 100644 --- a/src/main/java/project/flipnote/auth/model/UserLoginDto.java +++ b/src/main/java/project/flipnote/auth/model/UserLoginDto.java @@ -1,16 +1,16 @@ package project.flipnote.auth.model; import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotBlank; public class UserLoginDto { public record Request( - @Email @NotEmpty + @Email @NotBlank String email, - @NotEmpty + @NotBlank String password ) { } diff --git a/src/main/java/project/flipnote/auth/repository/EmailVerificationRedisRepository.java b/src/main/java/project/flipnote/auth/repository/EmailVerificationRedisRepository.java new file mode 100644 index 00000000..18ea4665 --- /dev/null +++ b/src/main/java/project/flipnote/auth/repository/EmailVerificationRedisRepository.java @@ -0,0 +1,63 @@ +package project.flipnote.auth.repository; + +import java.time.Duration; +import java.util.Optional; + +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import lombok.RequiredArgsConstructor; +import project.flipnote.auth.constants.AuthRedisKey; + +@RequiredArgsConstructor +@Repository +public class EmailVerificationRedisRepository { + + private final RedisTemplate emailRedisTemplate; + + public void saveCode(String email, String code) { + String key = AuthRedisKey.EMAIL_CODE.key(email); + Duration ttl = AuthRedisKey.EMAIL_CODE.getTtl(); + + emailRedisTemplate.opsForValue().set(key, code, ttl); + } + + public boolean existCode(String email) { + String key = AuthRedisKey.EMAIL_CODE.key(email); + + return emailRedisTemplate.hasKey(key); + } + + public Optional findCode(String email) { + String key = AuthRedisKey.EMAIL_CODE.key(email); + + String code = emailRedisTemplate.opsForValue().get(key); + + return Optional.ofNullable(code); + } + + public void deleteCode(String email) { + String key = AuthRedisKey.EMAIL_CODE.key(email); + + emailRedisTemplate.delete(key); + } + + public void markAsVerified(String email) { + String key = AuthRedisKey.EMAIL_VERIFIED.key(email); + Duration ttl = AuthRedisKey.EMAIL_VERIFIED.getTtl(); + + emailRedisTemplate.opsForValue().set(key, "1", ttl); + } + + public boolean isVerified(String email) { + String key = AuthRedisKey.EMAIL_VERIFIED.key(email); + + return emailRedisTemplate.hasKey(key); + } + + public void deleteVerified(String email) { + String key = AuthRedisKey.EMAIL_VERIFIED.key(email); + + emailRedisTemplate.delete(key); + } +} diff --git a/src/main/java/project/flipnote/auth/service/AuthService.java b/src/main/java/project/flipnote/auth/service/AuthService.java index 403fc95a..f8426723 100644 --- a/src/main/java/project/flipnote/auth/service/AuthService.java +++ b/src/main/java/project/flipnote/auth/service/AuthService.java @@ -1,19 +1,28 @@ package project.flipnote.auth.service; -import java.util.UUID; +import java.security.SecureRandom; +import java.util.Objects; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.auth.constants.VerificationConstants; +import project.flipnote.auth.event.EmailVerificationSendEvent; import project.flipnote.auth.exception.AuthErrorCode; +import project.flipnote.auth.model.EmailVerificationConfirmRequest; +import project.flipnote.auth.model.EmailVerificationRequest; import project.flipnote.auth.model.TokenPair; import project.flipnote.auth.model.UserLoginDto; +import project.flipnote.auth.repository.EmailVerificationRedisRepository; import project.flipnote.common.exception.BizException; import project.flipnote.common.security.jwt.JwtComponent; import project.flipnote.user.entity.User; import project.flipnote.user.repository.UserRepository; +@Slf4j @RequiredArgsConstructor @Service public class AuthService { @@ -21,13 +30,16 @@ public class AuthService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; private final JwtComponent jwtComponent; + private final EmailVerificationRedisRepository emailVerificationRedisRepository; + private final ApplicationEventPublisher eventPublisher; + + private static final SecureRandom random = new SecureRandom(); public TokenPair login(UserLoginDto.Request req) { User user = findByEmailOrThrow(req); - if (!passwordEncoder.matches(req.password(), user.getPassword())) { - throw new BizException(AuthErrorCode.INVALID_CREDENTIALS); - } + validatePasswordMatch(req.password(), user.getPassword()); + log.error("{}", user.getPhone()); return jwtComponent.generateTokenPair(user.getEmail(), user.getId(), user.getRole().name()); } @@ -36,4 +48,73 @@ private User findByEmailOrThrow(UserLoginDto.Request req) { return userRepository.findByEmail(req.email()) .orElseThrow(() -> new BizException(AuthErrorCode.INVALID_CREDENTIALS)); } + + private void validatePasswordMatch(String rawPassword, String encodedPassword) { + if (!passwordEncoder.matches(rawPassword, encodedPassword)) { + throw new BizException(AuthErrorCode.INVALID_CREDENTIALS); + } + } + + public void sendEmailVerificationCode(EmailVerificationRequest req) { + final String email = req.email(); + + validateEmailIsAvailable(email); + validateVerificationCodeNotExists(email); + + final String code = generateVerificationCode(VerificationConstants.CODE_LENGTH); + + emailVerificationRedisRepository.saveCode(email, code); + + eventPublisher.publishEvent(new EmailVerificationSendEvent(email, code)); + } + + private void validateEmailIsAvailable(String email) { + if (userRepository.existsByEmail(email)) { + throw new BizException(AuthErrorCode.EXISTING_EMAIL); + } + } + + private void validateVerificationCodeNotExists(String email) { + if (emailVerificationRedisRepository.existCode(email)) { + throw new BizException(AuthErrorCode.ALREADY_ISSUED_VERIFICATION_CODE); + } + } + + public void confirmEmailVerificationCode(EmailVerificationConfirmRequest req) { + String email = req.email(); + + String code = findVerificationCodeOrThrow(email); + + validateVerificationCode(req.code(), code); + + emailVerificationRedisRepository.deleteCode(email); + emailVerificationRedisRepository.markAsVerified(email); + } + + private String findVerificationCodeOrThrow(String email) { + return emailVerificationRedisRepository.findCode(email) + .orElseThrow(() -> new BizException(AuthErrorCode.NOT_ISSUED_VERIFICATION_CODE)); + } + + private void validateVerificationCode(String inputCode, String savedCode) { + if (!Objects.equals(inputCode, savedCode)) { + throw new BizException(AuthErrorCode.INVALID_VERIFICATION_CODE); + } + } + + public void validateEmail(String email) { + if (!emailVerificationRedisRepository.isVerified(email)) { + throw new BizException(AuthErrorCode.UNVERIFIED_EMAIL); + } + } + + public void deleteVerifiedEmail(String email) { + emailVerificationRedisRepository.deleteVerified(email); + } + + private String generateVerificationCode(int length) { + int origin = (int)Math.pow(10, length - 1); + int bound = (int)Math.pow(10, length); + return String.valueOf(random.nextInt(origin, bound)); + } } diff --git a/src/main/java/project/flipnote/common/config/AsyncConfig.java b/src/main/java/project/flipnote/common/config/AsyncConfig.java new file mode 100644 index 00000000..6e272b4c --- /dev/null +++ b/src/main/java/project/flipnote/common/config/AsyncConfig.java @@ -0,0 +1,29 @@ +package project.flipnote.common.config; + +import java.util.concurrent.Executor; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.AsyncConfigurer; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@EnableAsync +@Configuration +public class AsyncConfig implements AsyncConfigurer { + + private final AsyncProperties asyncProperties; + + @Override + public Executor getAsyncExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(asyncProperties.getCorePoolSize()); + executor.setMaxPoolSize(asyncProperties.getMaxPoolSize()); + executor.setQueueCapacity(asyncProperties.getQueueCapacity()); + executor.setThreadNamePrefix("Async-"); + executor.initialize(); + return executor; + } +} diff --git a/src/main/java/project/flipnote/common/config/AsyncProperties.java b/src/main/java/project/flipnote/common/config/AsyncProperties.java new file mode 100644 index 00000000..a0913b8a --- /dev/null +++ b/src/main/java/project/flipnote/common/config/AsyncProperties.java @@ -0,0 +1,26 @@ +package project.flipnote.common.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +import jakarta.validation.constraints.Positive; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "app.async") +@Component +public class AsyncProperties { + + @Positive + private int corePoolSize; + + @Positive + private int maxPoolSize; + + @Positive + private int queueCapacity; +} diff --git a/src/main/java/project/flipnote/common/config/RedisConfig.java b/src/main/java/project/flipnote/common/config/RedisConfig.java new file mode 100644 index 00000000..ea4e10dc --- /dev/null +++ b/src/main/java/project/flipnote/common/config/RedisConfig.java @@ -0,0 +1,20 @@ +package project.flipnote.common.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Bean + public RedisTemplate emailRedisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + template.setKeySerializer(new StringRedisSerializer()); + template.setValueSerializer(new StringRedisSerializer()); + return template; + } +} diff --git a/src/main/java/project/flipnote/common/constants/RedisKeys.java b/src/main/java/project/flipnote/common/constants/RedisKeys.java new file mode 100644 index 00000000..03c00cef --- /dev/null +++ b/src/main/java/project/flipnote/common/constants/RedisKeys.java @@ -0,0 +1,21 @@ +package project.flipnote.common.constants; + +import java.time.Duration; + +public interface RedisKeys { + String getPattern(); + + int getTtlSeconds(); + + default String key(Object... args) { + if (args == null || args.length == 0) { + throw new IllegalArgumentException("Arguments cannot be null or empty"); + } + + return String.format(getPattern(), args); + } + + default Duration getTtl() { + return Duration.ofSeconds(getTtlSeconds()); + } +} diff --git a/src/main/java/project/flipnote/common/crypto/AesCryptoConfig.java b/src/main/java/project/flipnote/common/crypto/AesCryptoConfig.java new file mode 100644 index 00000000..4f233933 --- /dev/null +++ b/src/main/java/project/flipnote/common/crypto/AesCryptoConfig.java @@ -0,0 +1,22 @@ +package project.flipnote.common.crypto; + +import java.nio.charset.StandardCharsets; + +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Configuration +public class AesCryptoConfig { + + private final AesCryptoProperties aesCryptoProperties; + + @Bean + public SecretKeySpec aesSecretKeySpec() { + return new SecretKeySpec(aesCryptoProperties.getKey().getBytes(StandardCharsets.UTF_8), "AES"); + } +} diff --git a/src/main/java/project/flipnote/common/crypto/AesCryptoConverter.java b/src/main/java/project/flipnote/common/crypto/AesCryptoConverter.java new file mode 100644 index 00000000..9e256078 --- /dev/null +++ b/src/main/java/project/flipnote/common/crypto/AesCryptoConverter.java @@ -0,0 +1,80 @@ +package project.flipnote.common.crypto; + +import java.nio.charset.StandardCharsets; +import java.security.SecureRandom; +import java.util.Base64; + +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Converter +@Component +public class AesCryptoConverter implements AttributeConverter { + + private final SecretKeySpec aesSecretKeySpec; + + private static final int IV_SIZE_BYTES = 16; + private static final String ALGORITHM = "AES/CBC/PKCS5Padding"; + + @Override + public String convertToDatabaseColumn(String attribute) { + if (!StringUtils.hasText(attribute)) { + return attribute; + } + + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + + byte[] iv = new byte[IV_SIZE_BYTES]; + new SecureRandom().nextBytes(iv); + IvParameterSpec ivParameterSpec = new IvParameterSpec(iv); + + cipher.init(Cipher.ENCRYPT_MODE, aesSecretKeySpec, ivParameterSpec); + byte[] encrypted = cipher.doFinal(attribute.getBytes(StandardCharsets.UTF_8)); + + byte[] combined = new byte[iv.length + encrypted.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length); + + return Base64.getEncoder().encodeToString(combined); + } catch (Exception e) { + throw new IllegalStateException("데이터 암호화에 실패했습니다.", e); + } + } + + @Override + public String convertToEntityAttribute(String dbData) { + if (!StringUtils.hasText(dbData)) { + return dbData; + } + + try { + Cipher cipher = Cipher.getInstance(ALGORITHM); + + byte[] combined = Base64.getDecoder().decode(dbData); + + byte[] iv = new byte[IV_SIZE_BYTES]; + System.arraycopy(combined, 0, iv, 0, iv.length); + IvParameterSpec ivParameterSpec = new IvParameterSpec(iv); + + byte[] encrypted = new byte[combined.length - iv.length]; + System.arraycopy(combined, iv.length, encrypted, 0, encrypted.length); + + cipher.init(Cipher.DECRYPT_MODE, aesSecretKeySpec, ivParameterSpec); + byte[] decrypted = cipher.doFinal(encrypted); + + return new String(decrypted, StandardCharsets.UTF_8); + } catch (Exception e) { + throw new IllegalStateException("데이터 복호화에 실패했습니다.", e); + } + } +} diff --git a/src/main/java/project/flipnote/common/crypto/AesCryptoProperties.java b/src/main/java/project/flipnote/common/crypto/AesCryptoProperties.java new file mode 100644 index 00000000..82669da1 --- /dev/null +++ b/src/main/java/project/flipnote/common/crypto/AesCryptoProperties.java @@ -0,0 +1,25 @@ +package project.flipnote.common.crypto; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Validated +@ConfigurationProperties(prefix = "app.encryption") +@Component +public class AesCryptoProperties { + + @NotBlank + @Pattern( + regexp = "^[A-Za-z0-9+/]{32}$", + message = "암호화 키는 32자의 Base64 문자열이어야 합니다." + ) + private String key; +} diff --git a/src/main/java/project/flipnote/common/exception/EmailSendException.java b/src/main/java/project/flipnote/common/exception/EmailSendException.java new file mode 100644 index 00000000..b23fd8c7 --- /dev/null +++ b/src/main/java/project/flipnote/common/exception/EmailSendException.java @@ -0,0 +1,7 @@ +package project.flipnote.common.exception; + +public class EmailSendException extends RuntimeException { + public EmailSendException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/project/flipnote/common/exception/GlobalExceptionHandler.java b/src/main/java/project/flipnote/common/exception/GlobalExceptionHandler.java index e9387584..2e428fb6 100644 --- a/src/main/java/project/flipnote/common/exception/GlobalExceptionHandler.java +++ b/src/main/java/project/flipnote/common/exception/GlobalExceptionHandler.java @@ -39,7 +39,9 @@ public ResponseEntity> handleGeneralError(Exception exception) } @ExceptionHandler(MethodArgumentNotValidException.class) - public ResponseEntity>> handleValidationError(MethodArgumentNotValidException exception) { + public ResponseEntity>> handleValidationError( + MethodArgumentNotValidException exception + ) { return ResponseEntity .badRequest() .body(ApiResponse.error(CommonErrorCode.INVALID_INPUT_VALUE, exception.getBindingResult())); diff --git a/src/main/java/project/flipnote/common/response/ApiResponse.java b/src/main/java/project/flipnote/common/response/ApiResponse.java index 332b2711..d21efcac 100644 --- a/src/main/java/project/flipnote/common/response/ApiResponse.java +++ b/src/main/java/project/flipnote/common/response/ApiResponse.java @@ -5,15 +5,15 @@ import org.springframework.validation.BindingResult; -import project.flipnote.common.exception.ErrorCode; - import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; +import project.flipnote.common.exception.ErrorCode; + @Getter @Builder -public class ApiResponse { +public class ApiResponse { private final int status; private final String code; diff --git a/src/main/java/project/flipnote/common/security/config/SecurityConfig.java b/src/main/java/project/flipnote/common/security/config/SecurityConfig.java index 1e01b80f..24346771 100644 --- a/src/main/java/project/flipnote/common/security/config/SecurityConfig.java +++ b/src/main/java/project/flipnote/common/security/config/SecurityConfig.java @@ -57,7 +57,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers(HttpMethod.POST, "/*/users").permitAll() - .requestMatchers(HttpMethod.POST, "/*/auth/login").permitAll() + .requestMatchers( + HttpMethod.POST, + "/*/auth/login", "/*/auth/email", "/*/auth/email/confirm" + ).permitAll() .requestMatchers( "/v3/api-docs/**", "/v3/api-docs", diff --git a/src/main/java/project/flipnote/common/security/filter/ExceptionHandlerFilter.java b/src/main/java/project/flipnote/common/security/filter/ExceptionHandlerFilter.java index 7c3ac21f..fd9ca058 100644 --- a/src/main/java/project/flipnote/common/security/filter/ExceptionHandlerFilter.java +++ b/src/main/java/project/flipnote/common/security/filter/ExceptionHandlerFilter.java @@ -41,4 +41,4 @@ private void setErrorResponse(HttpServletResponse response, SecurityException ex response.getWriter().write(new ObjectMapper().writeValueAsString(errorResponse)); } -} \ No newline at end of file +} diff --git a/src/main/java/project/flipnote/common/security/jwt/JwtProperties.java b/src/main/java/project/flipnote/common/security/jwt/JwtProperties.java index 9c53f486..1eee3eb5 100644 --- a/src/main/java/project/flipnote/common/security/jwt/JwtProperties.java +++ b/src/main/java/project/flipnote/common/security/jwt/JwtProperties.java @@ -13,7 +13,7 @@ @Getter @Setter @Component -@ConfigurationProperties(prefix = "jwt") +@ConfigurationProperties(prefix = "app.jwt") public class JwtProperties { private String secret; private Duration accessTokenExpiration; diff --git a/src/main/java/project/flipnote/common/util/CookieUtil.java b/src/main/java/project/flipnote/common/util/CookieUtil.java index baa1d102..7f6f5337 100644 --- a/src/main/java/project/flipnote/common/util/CookieUtil.java +++ b/src/main/java/project/flipnote/common/util/CookieUtil.java @@ -8,7 +8,14 @@ @NoArgsConstructor(access = AccessLevel.PRIVATE) public class CookieUtil { - public static void addCookie(HttpServletResponse response, String name, String value, int maxAge, boolean httpOnly, String path) { + public static void addCookie( + HttpServletResponse response, + String name, + String value, + int maxAge, + boolean httpOnly, + String path + ) { Cookie cookie = new Cookie(name, value); cookie.setMaxAge(maxAge); cookie.setHttpOnly(httpOnly); diff --git a/src/main/java/project/flipnote/common/validation/annotation/ValidPassword.java b/src/main/java/project/flipnote/common/validation/annotation/ValidPassword.java new file mode 100644 index 00000000..ce0f4aa8 --- /dev/null +++ b/src/main/java/project/flipnote/common/validation/annotation/ValidPassword.java @@ -0,0 +1,19 @@ +package project.flipnote.common.validation.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import project.flipnote.common.validation.validator.PasswordConstraintValidator; + +@Constraint(validatedBy = PasswordConstraintValidator.class) +@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidPassword { + String message() default "비밀번호 형식이 올바르지 않습니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/project/flipnote/common/validation/annotation/ValidPhone.java b/src/main/java/project/flipnote/common/validation/annotation/ValidPhone.java new file mode 100644 index 00000000..552cc6c1 --- /dev/null +++ b/src/main/java/project/flipnote/common/validation/annotation/ValidPhone.java @@ -0,0 +1,19 @@ +package project.flipnote.common.validation.annotation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import project.flipnote.common.validation.validator.PhoneConstraintValidator; + +@Constraint(validatedBy = PhoneConstraintValidator.class) +@Target({ ElementType.FIELD, ElementType.METHOD, ElementType.ANNOTATION_TYPE }) +@Retention(RetentionPolicy.RUNTIME) +public @interface ValidPhone { + String message() default "휴대전화 번호 형식이 올바르지 않습니다."; + Class[] groups() default {}; + Class[] payload() default {}; +} diff --git a/src/main/java/project/flipnote/common/validation/validator/PasswordConstraintValidator.java b/src/main/java/project/flipnote/common/validation/validator/PasswordConstraintValidator.java new file mode 100644 index 00000000..a789feff --- /dev/null +++ b/src/main/java/project/flipnote/common/validation/validator/PasswordConstraintValidator.java @@ -0,0 +1,24 @@ +package project.flipnote.common.validation.validator; + +import java.util.Objects; +import java.util.regex.Pattern; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import project.flipnote.common.validation.annotation.ValidPassword; + +public class PasswordConstraintValidator implements ConstraintValidator { + + private static final String PASSWORD_PATTERN = + "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[!@#$^*()_\\-])[A-Za-z\\d!@#$^*()_\\-]{8,16}$"; + private static final Pattern pattern = Pattern.compile(PASSWORD_PATTERN); + + @Override + public boolean isValid(String password, ConstraintValidatorContext context) { + if (Objects.isNull(password)) { + return false; + } + + return pattern.matcher(password).matches(); + } +} diff --git a/src/main/java/project/flipnote/common/validation/validator/PhoneConstraintValidator.java b/src/main/java/project/flipnote/common/validation/validator/PhoneConstraintValidator.java new file mode 100644 index 00000000..636d294e --- /dev/null +++ b/src/main/java/project/flipnote/common/validation/validator/PhoneConstraintValidator.java @@ -0,0 +1,21 @@ +package project.flipnote.common.validation.validator; + +import java.util.Objects; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; +import project.flipnote.common.validation.annotation.ValidPhone; + +public class PhoneConstraintValidator implements ConstraintValidator { + + private static final String PHONE_PATTERN = "^010-\\d{4}-\\d{4}$"; + + @Override + public boolean isValid(String phone, ConstraintValidatorContext context) { + if (Objects.isNull(phone)) { + return true; + } + + return phone.matches(PHONE_PATTERN); + } +} diff --git a/src/main/java/project/flipnote/infra/config/ResendConfig.java b/src/main/java/project/flipnote/infra/config/ResendConfig.java new file mode 100644 index 00000000..d8b242d3 --- /dev/null +++ b/src/main/java/project/flipnote/infra/config/ResendConfig.java @@ -0,0 +1,20 @@ +package project.flipnote.infra.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.resend.Resend; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Configuration +public class ResendConfig { + + private final ResendProperties resendProperties; + + @Bean + public Resend resend() { + return new Resend(resendProperties.getApiKey()); + } +} diff --git a/src/main/java/project/flipnote/infra/config/ResendProperties.java b/src/main/java/project/flipnote/infra/config/ResendProperties.java new file mode 100644 index 00000000..7b9268c1 --- /dev/null +++ b/src/main/java/project/flipnote/infra/config/ResendProperties.java @@ -0,0 +1,23 @@ +package project.flipnote.infra.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; +import org.springframework.validation.annotation.Validated; + +import jakarta.validation.constraints.NotEmpty; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Validated +@ConfigurationProperties("app.resend") +@Component +public class ResendProperties { + + @NotEmpty + private String fromEmail; + + @NotEmpty + private String apiKey; +} diff --git a/src/main/java/project/flipnote/infra/email/EmailService.java b/src/main/java/project/flipnote/infra/email/EmailService.java new file mode 100644 index 00000000..4cd13d38 --- /dev/null +++ b/src/main/java/project/flipnote/infra/email/EmailService.java @@ -0,0 +1,6 @@ +package project.flipnote.infra.email; + +public interface EmailService { + + void sendEmailVerificationCode(String to, String code, int ttl); +} diff --git a/src/main/java/project/flipnote/infra/email/ResendEmailService.java b/src/main/java/project/flipnote/infra/email/ResendEmailService.java new file mode 100644 index 00000000..dbc4b415 --- /dev/null +++ b/src/main/java/project/flipnote/infra/email/ResendEmailService.java @@ -0,0 +1,47 @@ +package project.flipnote.infra.email; + +import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; + +import com.resend.Resend; +import com.resend.core.exception.ResendException; +import com.resend.services.emails.model.CreateEmailOptions; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.common.exception.EmailSendException; +import project.flipnote.infra.config.ResendProperties; + +@Slf4j +@RequiredArgsConstructor +@Service +public class ResendEmailService implements EmailService { + + private final ResendProperties resendProperties; + private final Resend resend; + private final SpringTemplateEngine templateEngine; + + @Override + public void sendEmailVerificationCode(String to, String code, int ttl) { + Context context = new Context(); + context.setVariable("code", code); + context.setVariable("validMinutes", ttl); + + String html = templateEngine.process("email/email-verification", context); + + CreateEmailOptions params = CreateEmailOptions.builder() + .from(resendProperties.getFromEmail()) + .to(to) + .subject("이메일 인증번호 안내") + .html(html) + .build(); + + try { + resend.emails().send(params); + } catch (ResendException e) { + log.error("이메일 인증번호 발송 실패: to={}, ttl={}분", to, ttl, e); + throw new EmailSendException(e); + } + } +} diff --git a/src/main/java/project/flipnote/user/controller/UserController.java b/src/main/java/project/flipnote/user/controller/UserController.java index 32e08f0c..a98e67ee 100644 --- a/src/main/java/project/flipnote/user/controller/UserController.java +++ b/src/main/java/project/flipnote/user/controller/UserController.java @@ -9,7 +9,8 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import project.flipnote.user.model.UserRegisterDto; +import project.flipnote.user.model.UserRegisterRequest; +import project.flipnote.user.model.UserRegisterResponse; import project.flipnote.user.service.UserService; @RequiredArgsConstructor @@ -20,8 +21,8 @@ public class UserController { private final UserService userService; @PostMapping - public ResponseEntity register(@Valid @RequestBody UserRegisterDto.Request req) { - UserRegisterDto.Response res = userService.register(req); + public ResponseEntity register(@Valid @RequestBody UserRegisterRequest req) { + UserRegisterResponse res = userService.register(req); return ResponseEntity.status(HttpStatus.CREATED).body(res); } } diff --git a/src/main/java/project/flipnote/user/entity/User.java b/src/main/java/project/flipnote/user/entity/User.java index 98e9afe7..24e71942 100644 --- a/src/main/java/project/flipnote/user/entity/User.java +++ b/src/main/java/project/flipnote/user/entity/User.java @@ -1,32 +1,20 @@ package project.flipnote.user.entity; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.Collection; -import java.util.HashSet; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.authority.SimpleGrantedAuthority; - -import jakarta.persistence.CollectionTable; import jakarta.persistence.Column; -import jakarta.persistence.ElementCollection; +import jakarta.persistence.Convert; import jakarta.persistence.Entity; import jakarta.persistence.EnumType; import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import project.flipnote.common.crypto.AesCryptoConverter; import project.flipnote.common.entity.SoftDeletableEntity; @Getter @@ -53,7 +41,8 @@ public class User extends SoftDeletableEntity { private String profileImageUrl; - @Column(unique = true) + @Convert(converter = AesCryptoConverter.class) + @Column(unique = true, length = 1024) private String phone; private boolean smsAgree; diff --git a/src/main/java/project/flipnote/user/model/UserRegisterDto.java b/src/main/java/project/flipnote/user/model/UserRegisterDto.java deleted file mode 100644 index f471b479..00000000 --- a/src/main/java/project/flipnote/user/model/UserRegisterDto.java +++ /dev/null @@ -1,40 +0,0 @@ -package project.flipnote.user.model; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotEmpty; -import jakarta.validation.constraints.NotNull; - -public class UserRegisterDto { - - public record Request( - @Email @NotEmpty - String email, - - @NotEmpty - String password, - - @NotEmpty - String name, - - @NotEmpty - String nickname, - - @NotNull - Boolean smsAgree, - - @NotEmpty - String phone, - - String profileImageUrl - ) { - } - - public record Response( - Long userId - ) { - - public static Response from(Long userId) { - return new Response(userId); - } - } -} diff --git a/src/main/java/project/flipnote/user/model/UserRegisterRequest.java b/src/main/java/project/flipnote/user/model/UserRegisterRequest.java new file mode 100644 index 00000000..24e1cac5 --- /dev/null +++ b/src/main/java/project/flipnote/user/model/UserRegisterRequest.java @@ -0,0 +1,34 @@ +package project.flipnote.user.model; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import project.flipnote.common.validation.annotation.ValidPassword; +import project.flipnote.common.validation.annotation.ValidPhone; + +public record UserRegisterRequest( + @Email @NotBlank + String email, + + @ValidPassword + String password, + + @NotBlank + String name, + + @NotBlank + String nickname, + + @NotNull + Boolean smsAgree, + + @ValidPhone + String phone, + + String profileImageUrl +) { + + public String getCleanedPhone() { + return phone == null ? null : phone.replaceAll("-", ""); + } +} diff --git a/src/main/java/project/flipnote/user/model/UserRegisterResponse.java b/src/main/java/project/flipnote/user/model/UserRegisterResponse.java new file mode 100644 index 00000000..9b9f1a2a --- /dev/null +++ b/src/main/java/project/flipnote/user/model/UserRegisterResponse.java @@ -0,0 +1,10 @@ +package project.flipnote.user.model; + +public record UserRegisterResponse( + Long userId +) { + + public static UserRegisterResponse from(Long userId) { + return new UserRegisterResponse(userId); + } +} diff --git a/src/main/java/project/flipnote/user/service/UserService.java b/src/main/java/project/flipnote/user/service/UserService.java index e40852ca..05c00e0c 100644 --- a/src/main/java/project/flipnote/user/service/UserService.java +++ b/src/main/java/project/flipnote/user/service/UserService.java @@ -1,38 +1,53 @@ package project.flipnote.user.service; +import java.util.Objects; + import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; +import project.flipnote.auth.service.AuthService; import project.flipnote.common.exception.BizException; -import project.flipnote.user.model.UserRegisterDto; import project.flipnote.user.entity.User; import project.flipnote.user.exception.UserErrorCode; +import project.flipnote.user.model.UserRegisterRequest; +import project.flipnote.user.model.UserRegisterResponse; import project.flipnote.user.repository.UserRepository; @RequiredArgsConstructor +@Transactional(readOnly = true) @Service public class UserService { private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final AuthService authService; + + @Transactional + public UserRegisterResponse register(UserRegisterRequest req) { + String email = req.email(); + String phone = req.getCleanedPhone(); + + validateEmailDuplicate(email); + validatePhoneDuplicate(phone); - public UserRegisterDto.Response register(UserRegisterDto.Request req) { - validateEmailDuplicate(req.email()); - validatePhoneDuplicate(req.phone()); + authService.validateEmail(email); User user = User.builder() - .email(req.email()) + .email(email) .password(passwordEncoder.encode(req.password())) .name(req.name()) .nickname(req.nickname()) .smsAgree(req.smsAgree()) - .phone(req.phone()) + .phone(phone) .profileImageUrl(req.profileImageUrl()) .build(); - userRepository.save(user); + User savedUser = userRepository.save(user); - return UserRegisterDto.Response.from(user.getId()); + authService.deleteVerifiedEmail(email); + + return UserRegisterResponse.from(savedUser.getId()); } private void validateEmailDuplicate(String email) { @@ -42,8 +57,12 @@ private void validateEmailDuplicate(String email) { } private void validatePhoneDuplicate(String phone) { + if (Objects.isNull(phone)) { + return; + } + if (userRepository.existsByPhone(phone)) { - throw new BizException(UserErrorCode.DUPLICATE_PHONE); + throw new BizException(UserErrorCode.DUPLICATE_PHONE); } } } diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml deleted file mode 100644 index 44a93de9..00000000 --- a/src/main/resources/application-test.yml +++ /dev/null @@ -1,26 +0,0 @@ -spring: - application: - name: flipnote-test - - datasource: - url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 - driver-class-name: org.h2.Driver - username: sa - password: - - jpa: - open-in-view: false - hibernate: - ddl-auto: create-drop - - properties: - hibernate: - show_sql: true - format_sql: true - use_sql_comments: true - dialect: org.hibernate.dialect.H2Dialect - -jwt: - secret: ${JWT_SECRET:55ca298dcfc216e215622e3f48a251abaa4e8bb973074f065ab170e311acc15811d01a2407290c3ac143648196306d4a6f666a4ed364d3df633e08eb184bb0aea0f2edde4fd2d7fa68ea95ddbc421ff532ce47bde775975911042d665bc22d88a9fa26a03bb4d25530b8cdeb1247d87c9e3efcd721e368b0566b00a43308a729} - access-token-expiration: 1h - refresh-token-expiration: 7d \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7b70fd35..9329300b 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -4,22 +4,32 @@ spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://localhost:3306/flipnote?useSSL=false&allowPublicKeyRetrieval=true - username: ${MYSQL_USERNAME:root} - password: ${MYSQL_PASSWORD:root} + url: ${SPRING_DATASOURCE_URL} + username: ${SPRING_DATASOURCE_USERNAME} + password: ${SPRING_DATASOURCE_PASSWORD} jpa: open-in-view: false hibernate: - ddl-auto: update + ddl-auto: validate properties: hibernate: - show_sql: true - format_sql: true - use_sql_comments: true dialect: org.hibernate.dialect.MySQLDialect + thymeleaf: + prefix: classpath:/templates/ + suffix: .html + mode: HTML + encoding: UTF-8 + cache: false + + data: + redis: + host: ${SPRING_DATA_REDIS_HOST} + password: ${SPRING_DATA_REDIS_PASSWORD} + port: ${SPRING_DATA_REDIS_PORT} + management: endpoints: web: @@ -27,8 +37,20 @@ management: include: - health -jwt: - secret: ${JWT_SECRET:55ca298dcfc216e215622e3f48a251abaa4e8bb973074f065ab170e311acc15811d01a2407290c3ac143648196306d4a6f666a4ed364d3df633e08eb184bb0aea0f2edde4fd2d7fa68ea95ddbc421ff532ce47bde775975911042d665bc22d88a9fa26a03bb4d25530b8cdeb1247d87c9e3efcd721e368b0566b00a43308a729} - access-token-expiration: 1h - refresh-token-expiration: 7d +app: + jwt: + secret: ${APP_JWT_SECRET} + access-token-expiration: 1h + refresh-token-expiration: 7d + + async: + core-pool-size: 4 + max-pool-size: 10 + queue-capacity: 100 + + resend: + api-key: ${APP_RESEND_API_KEY} + from-email: FlipNote + encryption: + key: ${APP_ENCRYPTION_KEY} diff --git a/src/main/resources/templates/email/email-verification.html b/src/main/resources/templates/email/email-verification.html new file mode 100644 index 00000000..143a40a2 --- /dev/null +++ b/src/main/resources/templates/email/email-verification.html @@ -0,0 +1,71 @@ + + + + + 이메일 인증 + + + +
+
이메일 인증번호 안내
+
+ 아래 인증번호를 입력해 주세요.
+ 인증번호는 분간 유효합니다. +
+
123456
+ +
+ + diff --git a/src/test/java/project/flipnote/auth/service/AuthServiceTest.java b/src/test/java/project/flipnote/auth/service/AuthServiceTest.java new file mode 100644 index 00000000..8af9a35d --- /dev/null +++ b/src/test/java/project/flipnote/auth/service/AuthServiceTest.java @@ -0,0 +1,151 @@ +package project.flipnote.auth.service; + +import static org.assertj.core.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; + +import project.flipnote.auth.constants.VerificationConstants; +import project.flipnote.auth.event.EmailVerificationSendEvent; +import project.flipnote.auth.exception.AuthErrorCode; +import project.flipnote.auth.model.EmailVerificationConfirmRequest; +import project.flipnote.auth.model.EmailVerificationRequest; +import project.flipnote.auth.repository.EmailVerificationRedisRepository; +import project.flipnote.common.exception.BizException; +import project.flipnote.user.repository.UserRepository; + +@DisplayName("인증 서비스 단위 테스트") +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @InjectMocks + AuthService authService; + + @Mock + EmailVerificationRedisRepository emailVerificationRedisRepository; + + @Mock + UserRepository userRepository; + + @Mock + ApplicationEventPublisher eventPublisher; + + @DisplayName("이메일 인증번호 전송 테스트") + @Nested + class SendEmailVerificationCode { + + @DisplayName("성공") + @Test + void success() { + EmailVerificationRequest req = new EmailVerificationRequest("test@test.com"); + + given(userRepository.existsByEmail(any(String.class))).willReturn(false); + given(emailVerificationRedisRepository.existCode(any(String.class))).willReturn(false); + + authService.sendEmailVerificationCode(req); + + verify(emailVerificationRedisRepository, times(1)).saveCode(any(String.class), any(String.class)); + verify(eventPublisher, times(1)).publishEvent(any(EmailVerificationSendEvent.class)); + + int codeLength = VerificationConstants.CODE_LENGTH; + verify(emailVerificationRedisRepository).saveCode( + eq(req.email()), + argThat(code -> code.length() == codeLength && code.matches("\\d{%s}".formatted(codeLength))) + ); + } + + @DisplayName("가입된 이메일인 경우 예외 발생") + @Test + void fail_existingEmail() { + EmailVerificationRequest req = new EmailVerificationRequest("test@test.com"); + + given(userRepository.existsByEmail(any(String.class))).willReturn(true); + + BizException exception = assertThrows(BizException.class, () -> authService.sendEmailVerificationCode(req)); + assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.EXISTING_EMAIL); + + verify(emailVerificationRedisRepository, never()).saveCode(any(String.class), any(String.class)); + verify(eventPublisher, never()).publishEvent(any(EmailVerificationSendEvent.class)); + } + + @DisplayName("이미 발급된 인증번호가 존재할 경우 예외 발생") + @Test + void fail_alreadyIssuedVerificationCode() { + EmailVerificationRequest req = new EmailVerificationRequest("test@test.com"); + + given(userRepository.existsByEmail(any(String.class))).willReturn(false); + given(emailVerificationRedisRepository.existCode(any(String.class))).willReturn(true); + + BizException exception = assertThrows(BizException.class, () -> authService.sendEmailVerificationCode(req)); + assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.ALREADY_ISSUED_VERIFICATION_CODE); + + verify(emailVerificationRedisRepository, never()).saveCode(any(String.class), any(String.class)); + verify(eventPublisher, never()).publishEvent(any(EmailVerificationSendEvent.class)); + } + } + + @DisplayName("이메일 인증번호 확인 테스트") + @Nested + class ConfirmEmailVerificationCode { + + @DisplayName("성공") + @Test + void success() { + EmailVerificationConfirmRequest req = new EmailVerificationConfirmRequest("test@test.com", "123456"); + + given(emailVerificationRedisRepository.findCode("test@test.com")) + .willReturn(Optional.of("123456")); + + authService.confirmEmailVerificationCode(req); + + verify(emailVerificationRedisRepository, times(1)).deleteCode(any(String.class)); + verify(emailVerificationRedisRepository, times(1)).markAsVerified(any(String.class)); + } + + @DisplayName("발급된 인증번호가 없는 경우 예외 발생") + @Test + void fail_notIssuedVerificationCode() { + EmailVerificationConfirmRequest req = new EmailVerificationConfirmRequest("test@test.com", "123456"); + + given(emailVerificationRedisRepository.findCode("test@test.com")).willReturn(Optional.empty()); + + BizException exception = assertThrows( + BizException.class, + () -> authService.confirmEmailVerificationCode(req) + ); + assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.NOT_ISSUED_VERIFICATION_CODE); + + verify(emailVerificationRedisRepository, never()).deleteCode(any(String.class)); + verify(emailVerificationRedisRepository, never()).markAsVerified(any(String.class)); + } + + @DisplayName("잘못된 인증번호인 경우 예외 발생") + @Test + void fail_invalidVerificationCode() { + EmailVerificationConfirmRequest req = new EmailVerificationConfirmRequest("test@test.com", "123456"); + + given(emailVerificationRedisRepository.findCode("test@test.com")) + .willReturn(Optional.of("654321")); + + BizException exception = assertThrows( + BizException.class, + () -> authService.confirmEmailVerificationCode(req) + ); + assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.INVALID_VERIFICATION_CODE); + + verify(emailVerificationRedisRepository, never()).deleteCode(any(String.class)); + verify(emailVerificationRedisRepository, never()).markAsVerified(any(String.class)); + } + + } +} diff --git a/src/test/java/project/flipnote/common/validator/PasswordConstraintValidatorTest.java b/src/test/java/project/flipnote/common/validator/PasswordConstraintValidatorTest.java new file mode 100644 index 00000000..17a5e1a6 --- /dev/null +++ b/src/test/java/project/flipnote/common/validator/PasswordConstraintValidatorTest.java @@ -0,0 +1,58 @@ +package project.flipnote.common.validator; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.validation.ConstraintValidatorContext; +import project.flipnote.common.validation.validator.PasswordConstraintValidator; + +@DisplayName("비밀번호 유효성 검증기 단위 테스트") +@ExtendWith(MockitoExtension.class) +class PasswordConstraintValidatorTest { + + PasswordConstraintValidator validator = new PasswordConstraintValidator(); + + @Mock + ConstraintValidatorContext context; + + @Test + @DisplayName("유효한 비밀번호는 true를 반환한다") + void validPassword() { + assertThat(validator.isValid("Abc12345!", context)).isTrue(); + assertThat(validator.isValid("A1b2c3d4@", context)).isTrue(); + assertThat(validator.isValid("aB1!abcd", context)).isTrue(); + } + + @Test + @DisplayName("영문, 숫자, 특수문자 중 하나라도 빠지면 false") + void invalidPassword_missingType() { + assertThat(validator.isValid("abcdefgh", context)).isFalse(); + assertThat(validator.isValid("12345678!", context)).isFalse(); + assertThat(validator.isValid("Abcdefgh1", context)).isFalse(); + } + + @Test + @DisplayName("허용되지 않은 특수문자 포함 시 false") + void invalidPassword_wrongSpecialChar() { + assertThat(validator.isValid("Abc12345%", context)).isFalse(); + assertThat(validator.isValid("Abc12345?", context)).isFalse(); + } + + @Test + @DisplayName("길이 제한 위반 시 false") + void invalidPassword_length() { + assertThat(validator.isValid("A1!a", context)).isFalse(); + assertThat(validator.isValid("Abcdefghijk1!2345", context)).isFalse(); + } + + @Test + @DisplayName("null 입력 시 false") + void nullPassword() { + assertThat(validator.isValid(null, context)).isFalse(); + } +} diff --git a/src/test/java/project/flipnote/common/validator/PhoneConstraintValidatorTest.java b/src/test/java/project/flipnote/common/validator/PhoneConstraintValidatorTest.java new file mode 100644 index 00000000..c8a8ca34 --- /dev/null +++ b/src/test/java/project/flipnote/common/validator/PhoneConstraintValidatorTest.java @@ -0,0 +1,71 @@ +package project.flipnote.common.validator; + +import static org.assertj.core.api.Assertions.*; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import jakarta.validation.ConstraintValidatorContext; +import project.flipnote.common.validation.validator.PhoneConstraintValidator; + +@DisplayName("휴대폰 번호 유효성 검증기 단위 테스트") +@ExtendWith(MockitoExtension.class) +class PhoneConstraintValidatorTest { + + PhoneConstraintValidator validator = new PhoneConstraintValidator(); + + @Mock + ConstraintValidatorContext context; + + @Test + @DisplayName("010-1234-5678 형식은 true를 반환한다") + void validPhoneWithHyphens() { + assertThat(validator.isValid("010-1234-5678", context)).isTrue(); + assertThat(validator.isValid("010-4567-1238", context)).isTrue(); + } + + @Test + @DisplayName("null은 true를 반환한다") + void validPhoneNull() { + assertThat(validator.isValid(null, context)).isTrue(); + } + + @Test + @DisplayName("01012345678 형식은 false를 반환한다") + void validPhoneWithoutHyphens() { + assertThat(validator.isValid("01012345678", context)).isFalse(); + } + + @Test + @DisplayName("010-123-4567 등 잘못된 자리수는 false") + void invalidPhoneWrongDigits() { + assertThat(validator.isValid("010-123-4567", context)).isFalse(); + assertThat(validator.isValid("010-12345-6789", context)).isFalse(); + assertThat(validator.isValid("010-12345-678", context)).isFalse(); + assertThat(validator.isValid("010-12345-67890", context)).isFalse(); + } + + @Test + @DisplayName("010이 아닌 번호는 false") + void invalidPhoneNot010() { + assertThat(validator.isValid("011-1234-5678", context)).isFalse(); + assertThat(validator.isValid("019-1234-5678", context)).isFalse(); + } + + @Test + @DisplayName("숫자가 아닌 문자가 포함되면 false") + void invalidPhoneWithLetters() { + assertThat(validator.isValid("010-ABCD-5678", context)).isFalse(); + assertThat(validator.isValid("0101234abcd", context)).isFalse(); + } + + @Test + @DisplayName("빈 문자열은 false") + void invalidPhoneEmpty() { + assertThat(validator.isValid("", context)).isFalse(); + assertThat(validator.isValid(" ", context)).isFalse(); + } +} diff --git a/src/test/java/project/flipnote/user/service/UserServiceTest.java b/src/test/java/project/flipnote/user/service/UserServiceTest.java new file mode 100644 index 00000000..67b8617a --- /dev/null +++ b/src/test/java/project/flipnote/user/service/UserServiceTest.java @@ -0,0 +1,154 @@ +package project.flipnote.user.service; + +import static org.assertj.core.api.AssertionsForClassTypes.*; +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.BDDMockito.*; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.test.util.ReflectionTestUtils; + +import project.flipnote.auth.exception.AuthErrorCode; +import project.flipnote.auth.service.AuthService; +import project.flipnote.common.exception.BizException; +import project.flipnote.user.entity.User; +import project.flipnote.user.exception.UserErrorCode; +import project.flipnote.user.model.UserRegisterRequest; +import project.flipnote.user.model.UserRegisterResponse; +import project.flipnote.user.repository.UserRepository; + +@DisplayName("회원 서비스 단위 테스트") +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @InjectMocks + private UserService userService; + + @Mock + private UserRepository userRepository; + + @Mock + private AuthService authService; + + @Mock + private PasswordEncoder passwordEncoder; + + private User user; + + @BeforeEach + void init() { + user = User.builder() + .email("test@test.com") + .password("testPass") + .name("테스트") + .nickname("테스트") + .smsAgree(false) + .phone("010-1234-5678") + .profileImageUrl(null) + .build(); + + ReflectionTestUtils.setField(user, "id", 1L); + } + + @DisplayName("회원가입 테스트") + @Nested + class Register { + + @DisplayName("성공") + @Test + void success() { + UserRegisterRequest req = new UserRegisterRequest( + "test@test.com", "testPass", "테스트", "테스트", false, "010-1234-5678", "" + ); + + given(userRepository.existsByEmail(any(String.class))).willReturn(false); + given(userRepository.existsByPhone(any(String.class))).willReturn(false); + given(passwordEncoder.encode(any(String.class))).willReturn("encodedPass"); + given(userRepository.save(any(User.class))).willReturn(user); + + UserRegisterResponse res = userService.register(req); + + assertThat(res.userId()).isEqualTo(user.getId()); + + verify(authService, times(1)).validateEmail(any(String.class)); + verify(authService, times(1)).deleteVerifiedEmail(any(String.class)); + } + + @DisplayName("휴대전화 번호가 null일 때 성공") + @Test + void success_ifPhoneIsNull() { + UserRegisterRequest req = new UserRegisterRequest( + "test@test.com", "testPass", "테스트", "테스트", false, null, null + ); + + given(userRepository.existsByEmail(any(String.class))).willReturn(false); + given(passwordEncoder.encode(any(String.class))).willReturn("encodedPass"); + given(userRepository.save(any(User.class))).willReturn(user); + + UserRegisterResponse res = userService.register(req); + + assertThat(res.userId()).isEqualTo(user.getId()); + + verify(authService, times(1)).validateEmail(any(String.class)); + verify(authService, times(1)).deleteVerifiedEmail(any(String.class)); + } + + @DisplayName("이메일 중복 시 예외 발생") + @Test + void fail_duplicateEmail() { + UserRegisterRequest req = new UserRegisterRequest( + "test@test.com", "testPass", "테스트", "테스트", false, "010-1234-5678", "" + ); + + given(userRepository.existsByEmail(any(String.class))).willReturn(true); + + BizException exception = assertThrows(BizException.class, () -> userService.register(req)); + assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.DUPLICATE_EMAIL); + + verify(userRepository, never()).existsByPhone(any(String.class)); + verify(userRepository, never()).save(any(User.class)); + } + + @DisplayName("전화번호 중복 시 예외 발생") + @Test + void fail_duplicatePhone() { + UserRegisterRequest req = new UserRegisterRequest( + "test@test.com", "testPass", "테스트", "테스트", false, "010-1234-5678", "" + ); + + given(userRepository.existsByEmail(any(String.class))).willReturn(false); + given(userRepository.existsByPhone(any(String.class))).willReturn(true); + + BizException exception = assertThrows(BizException.class, () -> userService.register(req)); + assertThat(exception.getErrorCode()).isEqualTo(UserErrorCode.DUPLICATE_PHONE); + + verify(userRepository, never()).save(any()); + } + + @DisplayName("이메일 인증이 안 된 경우 예외 발생") + @Test + void fail_unverifiedEmail() { + UserRegisterRequest req = new UserRegisterRequest( + "test@test.com", "testPass", "테스트", "테스트", false, "010-1234-5678", "" + ); + + given(userRepository.existsByEmail(any(String.class))).willReturn(false); + given(userRepository.existsByPhone(any(String.class))).willReturn(false); + doThrow(new BizException(AuthErrorCode.UNVERIFIED_EMAIL)) + .when(authService).validateEmail(any(String.class)); + + BizException exception = assertThrows(BizException.class, () -> userService.register(req)); + assertThat(exception.getErrorCode()).isEqualTo(AuthErrorCode.UNVERIFIED_EMAIL); + + verify(userRepository, never()).save(any(User.class)); + } + } + +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml new file mode 100644 index 00000000..1618bf1e --- /dev/null +++ b/src/test/resources/application-test.yml @@ -0,0 +1,31 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 + driver-class-name: org.h2.Driver + username: sa + password: + + jpa: + hibernate: + ddl-auto: create-drop + + properties: + hibernate: + show_sql: true + format_sql: true + use_sql_comments: true + + data: + redis: + host: localhost + port: 6379 + +app: + jwt: + secret: fBwvzR4gS8pL7C2R5E9Q3aV6y9wB2b8HkM5nP3sUaX7gS1rV4wE8yD2bF6jN9kL4 + + resend: + api-key: dummy_api_key + + encryption: + key: 49015f426db2b8477d5251fd3de971ae