From fba64739ba1c10c31dad1b56c469e75fc46728d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Thu, 6 Aug 2026 16:21:49 +0900 Subject: [PATCH 1/8] =?UTF-8?q?chore:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B8=B0=EB=B0=98=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - spring-boot-starter-mail 의존성 및 Gmail SMTP 설정 추가 - BCryptPasswordEncoder 빈 등록 - 메일 발송 전용 비동기 스레드풀 구성 - 이메일 인증 정책 값을 app.email-verification 으로 분리 - 신규 인증 엔드포인트 5개를 permitAll 에 등록 --- .env.example | 6 ++++ build.gradle | 1 + .../com/slatto/global/config/AsyncConfig.java | 32 +++++++++++++++++++ .../slatto/global/config/SecurityConfig.java | 16 +++++++++- .../EmailVerificationProperties.java | 15 +++++++++ .../properties/MailSenderProperties.java | 10 ++++++ src/main/resources/application.yml | 26 +++++++++++++++ src/test/resources/application.yml | 17 ++++++++++ 8 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/slatto/global/config/AsyncConfig.java create mode 100644 src/main/java/com/slatto/global/config/properties/EmailVerificationProperties.java create mode 100644 src/main/java/com/slatto/global/config/properties/MailSenderProperties.java diff --git a/.env.example b/.env.example index 5c4a3c2a..383e1d1f 100644 --- a/.env.example +++ b/.env.example @@ -32,6 +32,12 @@ COOKIE_SAME_SITE=Lax # POST /api/v1/auth/refresh의 CSRF 방어에도 쓰인다. 이 목록에 없는 오리진의 재발급 요청은 403으로 차단된다 CORS_ALLOWED_ORIGINS=http://localhost:3000 +# 인증번호 발송용 Gmail SMTP 계정 +# MAIL_PASSWORD는 계정 비밀번호가 아니라 2단계 인증을 켠 뒤 발급한 16자리 앱 비밀번호다 +MAIL_USERNAME=slatto.official.kr@gmail.com +MAIL_PASSWORD= +MAIL_FROM_NAME=슬레이투 + # S3 dev 테스트 AWS_ACCESS_KEY_ID= AWS_SECRET_ACCESS_KEY= diff --git a/build.gradle b/build.gradle index 0418a096..4f52725d 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.flywaydb:flyway-core' implementation 'org.flywaydb:flyway-mysql' implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.16' diff --git a/src/main/java/com/slatto/global/config/AsyncConfig.java b/src/main/java/com/slatto/global/config/AsyncConfig.java new file mode 100644 index 00000000..da487c09 --- /dev/null +++ b/src/main/java/com/slatto/global/config/AsyncConfig.java @@ -0,0 +1,32 @@ +package com.slatto.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +import java.util.concurrent.Executor; +import java.util.concurrent.ThreadPoolExecutor; + +@Configuration +@EnableAsync +public class AsyncConfig { + + public static final String MAIL_EXECUTOR = "mailExecutor"; + + // 메일 전용 풀이다. 공용 풀을 쓰면 SMTP 지연이 다른 비동기 작업까지 함께 막는다. + // 큐가 차면 호출 스레드가 직접 실행한다. 인증번호는 버리는 것보다 늦게라도 나가는 편이 낫다. + @Bean(name = MAIL_EXECUTOR) + public Executor mailExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(2); + executor.setMaxPoolSize(4); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("mail-"); + executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.initialize(); + + return executor; + } + +} diff --git a/src/main/java/com/slatto/global/config/SecurityConfig.java b/src/main/java/com/slatto/global/config/SecurityConfig.java index 28d68c55..cc337614 100644 --- a/src/main/java/com/slatto/global/config/SecurityConfig.java +++ b/src/main/java/com/slatto/global/config/SecurityConfig.java @@ -11,6 +11,8 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.http.SessionCreationPolicy; +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; import org.springframework.web.cors.CorsConfiguration; @@ -40,10 +42,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .httpBasic(httpBasic -> httpBasic.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth + // /login/** 은 /api/v1/auth/login 자체도 매칭한다(** 는 0개 세그먼트도 받는다). + // 이메일 로그인이 여기 묻히지 않도록 명시적으로 나열한다. .requestMatchers( "/api/v1/auth/login/**", "/api/v1/auth/callback/**", - "/api/v1/auth/refresh" + "/api/v1/auth/refresh", + "/api/v1/auth/login", + "/api/v1/auth/signup", + "/api/v1/auth/password/reset", + "/api/v1/auth/email/verification-codes", + "/api/v1/auth/email/verification-codes/confirm" ).permitAll() // 게스트 등록 .requestMatchers(HttpMethod.POST, "/api/v1/share-links/*/guests").permitAll() @@ -74,6 +83,11 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .build(); } + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); diff --git a/src/main/java/com/slatto/global/config/properties/EmailVerificationProperties.java b/src/main/java/com/slatto/global/config/properties/EmailVerificationProperties.java new file mode 100644 index 00000000..fa7122eb --- /dev/null +++ b/src/main/java/com/slatto/global/config/properties/EmailVerificationProperties.java @@ -0,0 +1,15 @@ +package com.slatto.global.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "app.email-verification") +public record EmailVerificationProperties( + Duration codeValidity, + Duration resendCooldown, + Duration verifiedValidity, + int maxSendPerHour, + int maxAttempts +) { +} diff --git a/src/main/java/com/slatto/global/config/properties/MailSenderProperties.java b/src/main/java/com/slatto/global/config/properties/MailSenderProperties.java new file mode 100644 index 00000000..eb4d0dad --- /dev/null +++ b/src/main/java/com/slatto/global/config/properties/MailSenderProperties.java @@ -0,0 +1,10 @@ +package com.slatto.global.config.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties(prefix = "app.mail") +public record MailSenderProperties( + String fromAddress, + String fromName +) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index a38f6ee5..d35ad207 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,6 +23,20 @@ spring: hibernate: format_sql: true + mail: + host: smtp.gmail.com + port: 587 + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail.smtp.auth: true + mail.smtp.starttls.enable: true + mail.smtp.starttls.required: true + # 타임아웃이 없으면 SMTP 가 늦어질 때 발송 스레드가 무한정 잡힌다. + mail.smtp.connectiontimeout: 5000 + mail.smtp.timeout: 5000 + mail.smtp.writetimeout: 5000 + flyway: # 기존 운영/개발 DB는 수동 반영된 V8 스키마를 기준점으로 등록한 뒤 이후 변경만 적용한다. baseline-on-migrate: true @@ -93,3 +107,15 @@ app: cors: allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000} + + mail: + from-address: ${MAIL_USERNAME} + from-name: ${MAIL_FROM_NAME:슬레이투} + + email-verification: + code-validity: PT5M + resend-cooldown: PT1M + # 인증 확인 후 회원가입·비밀번호 재설정을 마쳐야 하는 시간 + verified-validity: PT30M + max-send-per-hour: 5 + max-attempts: 5 diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index 1db07238..bceb3c62 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -19,6 +19,12 @@ spring: flyway: enabled: false + mail: + host: localhost + port: 3025 + username: test@example.com + password: test-password + cloud: aws: s3: @@ -68,3 +74,14 @@ app: same-site: Strict oauth-state-same-site: Lax oauth-state-max-age: PT5M + + mail: + from-address: test@example.com + from-name: 슬레이투 + + email-verification: + code-validity: PT5M + resend-cooldown: PT1M + verified-validity: PT30M + max-send-per-hour: 5 + max-attempts: 5 From 79542c56576b71452ab40379e8b6da4320f7e160 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Thu, 6 Aug 2026 16:24:44 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=8A=A4=ED=82=A4=EB=A7=88=20=EB=B0=8F=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - V011__email_verification 마이그레이션 추가 - EmailVerification 엔티티와 VerificationPurpose enum 추가 - 발송 이력 조회, 시간당 발송 집계, 만료 행 정리 쿼리 추가 - Users 에 이메일 가입 정적 팩토리와 비밀번호 변경 메서드 추가 --- .../domain/auth/entity/EmailVerification.java | 120 ++++++++++++++++++ .../auth/enums/VerificationPurpose.java | 6 + .../EmailVerificationRepository.java | 45 +++++++ .../com/slatto/domain/user/entity/Users.java | 16 +++ .../db/migration/V011__email_verification.sql | 21 +++ 5 files changed, 208 insertions(+) create mode 100644 src/main/java/com/slatto/domain/auth/entity/EmailVerification.java create mode 100644 src/main/java/com/slatto/domain/auth/enums/VerificationPurpose.java create mode 100644 src/main/java/com/slatto/domain/auth/repository/EmailVerificationRepository.java create mode 100644 src/main/resources/db/migration/V011__email_verification.sql diff --git a/src/main/java/com/slatto/domain/auth/entity/EmailVerification.java b/src/main/java/com/slatto/domain/auth/entity/EmailVerification.java new file mode 100644 index 00000000..a2f38772 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/entity/EmailVerification.java @@ -0,0 +1,120 @@ +package com.slatto.domain.auth.entity; + +import com.slatto.domain.auth.enums.VerificationPurpose; +import com.slatto.domain.common.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.Duration; +import java.time.LocalDateTime; + +@Entity +@Table( + name = "email_verification", + indexes = @Index(name = "idx_email_verification_email_purpose", columnList = "email, purpose, id") +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class EmailVerification extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id", nullable = false) + private Long id; + + @Column(name = "email", nullable = false, length = 255) + private String email; + + @Enumerated(EnumType.STRING) + @Column(name = "purpose", nullable = false, length = 30) + private VerificationPurpose purpose; + + // 평문 인증번호는 저장하지 않는다. SHA-256 hex 64자다. + @Column(name = "code_hash", nullable = false, length = 64) + private String codeHash; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(name = "verified_at", nullable = true) + private LocalDateTime verifiedAt; + + @Column(name = "consumed_at", nullable = true) + private LocalDateTime consumedAt; + + @Column(name = "attempt_count", nullable = false) + private Integer attemptCount; + + private EmailVerification( + String email, + VerificationPurpose purpose, + String codeHash, + LocalDateTime expiresAt + ) { + this.email = email; + this.purpose = purpose; + this.codeHash = codeHash; + this.expiresAt = expiresAt; + this.attemptCount = 0; + } + + public static EmailVerification issue( + String email, + VerificationPurpose purpose, + String codeHash, + LocalDateTime expiresAt + ) { + return new EmailVerification(email, purpose, codeHash, expiresAt); + } + + public boolean isExpired(LocalDateTime now) { + return expiresAt.isBefore(now); + } + + public boolean isConsumed() { + return consumedAt != null; + } + + public boolean isVerified() { + return verifiedAt != null; + } + + public boolean matches(String candidateCodeHash) { + return codeHash.equals(candidateCodeHash); + } + + public boolean hasAttemptsLeft(int maxAttempts) { + return attemptCount < maxAttempts; + } + + // 인증 확인 후 회원가입·재설정을 마쳐야 하는 제한 시간이다. + public boolean isVerificationAlive(LocalDateTime now, Duration verifiedValidity) { + return isVerified() + && !isConsumed() + && verifiedAt.plus(verifiedValidity).isAfter(now); + } + + public LocalDateTime verifiedUntil(Duration verifiedValidity) { + return verifiedAt == null ? null : verifiedAt.plus(verifiedValidity); + } + + public void increaseAttemptCount() { + this.attemptCount++; + } + + // 시도 횟수를 소진했거나 새 코드가 발급되면 이 행을 즉시 만료시킨다. + public void invalidate(LocalDateTime now) { + this.expiresAt = now.minusNanos(1); + } + + public void markVerified(LocalDateTime now) { + this.verifiedAt = now; + } + + public void markConsumed(LocalDateTime now) { + this.consumedAt = now; + } + +} diff --git a/src/main/java/com/slatto/domain/auth/enums/VerificationPurpose.java b/src/main/java/com/slatto/domain/auth/enums/VerificationPurpose.java new file mode 100644 index 00000000..bbf6f12d --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/enums/VerificationPurpose.java @@ -0,0 +1,6 @@ +package com.slatto.domain.auth.enums; + +public enum VerificationPurpose { + SIGNUP, + PASSWORD_RESET +} diff --git a/src/main/java/com/slatto/domain/auth/repository/EmailVerificationRepository.java b/src/main/java/com/slatto/domain/auth/repository/EmailVerificationRepository.java new file mode 100644 index 00000000..0aeef554 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/repository/EmailVerificationRepository.java @@ -0,0 +1,45 @@ +package com.slatto.domain.auth.repository; + +import com.slatto.domain.auth.entity.EmailVerification; +import com.slatto.domain.auth.enums.VerificationPurpose; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.Optional; + +@Repository +public interface EmailVerificationRepository extends JpaRepository { + + Optional findFirstByEmailAndPurposeOrderByIdDesc( + String email, + VerificationPurpose purpose + ); + + // 시간당 발송 한도 계산용. 만료 여부와 무관하게 발송 이력 자체를 센다. + long countByEmailAndPurposeAndCreatedAtAfter( + String email, + VerificationPurpose purpose, + LocalDateTime createdAtAfter + ); + + // 별도 정리 스케줄러를 두지 않는다. 새 인증번호를 발송할 때 같은 이메일의 죽은 행을 함께 지운다. + // 아직 쓸 수 있는 인증(verified 유효 구간)은 남겨야 하므로 consumed 이거나 인증 전인 행만 대상으로 한다. + // threshold 는 시간당 한도 집계 구간보다 앞서야 한다. 최근 1시간 행을 지우면 발송 횟수가 리셋된다. + // clearAutomatically 를 쓰지 않는다. 영속 엔티티를 detach 시켜 이후 조회가 꼬인다. + @Modifying + @Query(""" + delete from EmailVerification ev + where ev.email = :email + and ev.expiresAt < :threshold + and (ev.verifiedAt is null or ev.consumedAt is not null) + """) + int deleteDeadRows( + @Param("email") String email, + @Param("threshold") LocalDateTime threshold + ); + +} diff --git a/src/main/java/com/slatto/domain/user/entity/Users.java b/src/main/java/com/slatto/domain/user/entity/Users.java index 8b0813c5..3db81442 100644 --- a/src/main/java/com/slatto/domain/user/entity/Users.java +++ b/src/main/java/com/slatto/domain/user/entity/Users.java @@ -71,6 +71,22 @@ public static Users createSocialUser( return new Users(email, nickname, profileImageUrl, socialType, socialId); } + public static Users createEmailUser(String email, String nickname, String encodedPassword) { + Users user = new Users(email, nickname, null, SocialType.EMAIL, null); + user.password = encodedPassword; + + return user; + } + + public void changePassword(String encodedPassword) { + this.password = encodedPassword; + } + + // 소셜로만 가입해 비밀번호가 없는 계정과 이메일 가입 계정을 구분한다. + public boolean hasPassword() { + return password != null; + } + public void linkSocialAccount(SocialType socialType, String socialId) { this.socialType = socialType; this.socialId = socialId; diff --git a/src/main/resources/db/migration/V011__email_verification.sql b/src/main/resources/db/migration/V011__email_verification.sql new file mode 100644 index 00000000..07925e6d --- /dev/null +++ b/src/main/resources/db/migration/V011__email_verification.sql @@ -0,0 +1,21 @@ +-- 이메일 회원가입·비밀번호 재설정에 쓰는 인증번호 발송·확인 이력을 저장한다. + +CREATE TABLE email_verification +( + id BIGINT NOT NULL AUTO_INCREMENT, + email VARCHAR(255) NOT NULL, + purpose VARCHAR(30) NOT NULL, + code_hash VARCHAR(64) NOT NULL, + expires_at DATETIME(6) NOT NULL, + verified_at DATETIME(6) NULL, + consumed_at DATETIME(6) NULL, + attempt_count INT NOT NULL DEFAULT 0, + created_at DATETIME(6) NOT NULL, + updated_at DATETIME(6) NOT NULL, + PRIMARY KEY (id) +) ENGINE = InnoDB + DEFAULT CHARSET = utf8mb4; + +-- 최신 발송 건 조회와 쿨다운/시간당 한도 계산이 모두 (email, purpose) 로 걸린다. +CREATE INDEX idx_email_verification_email_purpose + ON email_verification (email, purpose, id); From d087f35668a424af500a416ac544c16ab0a203d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Thu, 6 Aug 2026 16:29:08 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EB=B2=88=ED=98=B8=20=EB=B0=9C=EC=86=A1=C2=B7?= =?UTF-8?q?=ED=99=95=EC=9D=B8=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /auth/email/verification-codes, /confirm 추가 - 인증번호는 SHA-256 해시로 저장하고 평문을 남기지 않는다 - 재발송 60초 쿨다운, 시간당 5회 발송 제한, 코드당 5회 시도 제한 - 발송은 커밋 이후 비동기로 처리하고 실패는 로그만 남긴다 - 계정이 없는 비밀번호 재설정 요청은 메일만 보내지 않고 응답은 동일하게 성공 - AuthErrorCode 에 인증·가입·로그인 코드 7개 추가 --- .../auth/controller/AuthController.java | 53 ++++++ .../dto/EmailVerificationConfirmRequest.java | 22 +++ .../dto/EmailVerificationConfirmResponse.java | 6 + .../dto/EmailVerificationSendRequest.java | 19 +++ .../dto/EmailVerificationSendResponse.java | 9 + .../domain/auth/exception/AuthErrorCode.java | 13 +- .../service/EmailVerificationService.java | 161 ++++++++++++++++++ .../auth/service/VerificationMailSender.java | 69 ++++++++ 8 files changed, 351 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/slatto/domain/auth/dto/EmailVerificationConfirmRequest.java create mode 100644 src/main/java/com/slatto/domain/auth/dto/EmailVerificationConfirmResponse.java create mode 100644 src/main/java/com/slatto/domain/auth/dto/EmailVerificationSendRequest.java create mode 100644 src/main/java/com/slatto/domain/auth/dto/EmailVerificationSendResponse.java create mode 100644 src/main/java/com/slatto/domain/auth/service/EmailVerificationService.java create mode 100644 src/main/java/com/slatto/domain/auth/service/VerificationMailSender.java diff --git a/src/main/java/com/slatto/domain/auth/controller/AuthController.java b/src/main/java/com/slatto/domain/auth/controller/AuthController.java index 0b54ee57..3d41d08e 100644 --- a/src/main/java/com/slatto/domain/auth/controller/AuthController.java +++ b/src/main/java/com/slatto/domain/auth/controller/AuthController.java @@ -1,7 +1,12 @@ package com.slatto.domain.auth.controller; import com.slatto.domain.auth.dto.AccessTokenResponse; +import com.slatto.domain.auth.dto.EmailVerificationConfirmRequest; +import com.slatto.domain.auth.dto.EmailVerificationConfirmResponse; +import com.slatto.domain.auth.dto.EmailVerificationSendRequest; +import com.slatto.domain.auth.dto.EmailVerificationSendResponse; import com.slatto.domain.auth.service.AuthService; +import com.slatto.domain.auth.service.EmailVerificationService; import com.slatto.domain.auth.support.AuthCookieFactory; import com.slatto.global.response.ApiResponse; import com.slatto.global.response.code.CommonSuccessCode; @@ -9,14 +14,18 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirements; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; import java.net.URI; @@ -28,6 +37,7 @@ public class AuthController { private final AuthService authService; + private final EmailVerificationService emailVerificationService; private final AuthCookieFactory authCookieFactory; @Operation( @@ -93,6 +103,49 @@ public ApiResponse reissueAccessToken( return ApiResponse.success(CommonSuccessCode.OK, authService.reissueAccessToken(refreshToken)); } + @Operation( + summary = "이메일 인증번호 발송", + description = """ + 입력한 이메일로 6자리 인증번호를 발송한다. 인증번호는 5분간 유효하다. + + 회원가입(`SIGNUP`)과 비밀번호 재설정(`PASSWORD_RESET`)이 같은 엔드포인트를 쓰며 `purpose`로 구분한다. + + **가입 여부와 무관하게 항상 성공을 반환한다.** 계정 존재 여부를 노출하면 아무 이메일이나 + 넣어보고 가입 여부를 알아낼 수 있기 때문이다. + """ + ) + @SecurityRequirements + @ResponseStatus(HttpStatus.CREATED) + @PostMapping("/email/verification-codes") + public ApiResponse sendEmailVerificationCode( + @Valid @RequestBody EmailVerificationSendRequest request + ) { + EmailVerificationSendResponse response = emailVerificationService.send(request.email(), request.purpose()); + + return ApiResponse.success(CommonSuccessCode.CREATED, response); + } + + @Operation( + summary = "이메일 인증번호 확인", + description = """ + 인증번호를 확인하고 해당 이메일을 인증 완료 상태로 만든다. + 성공 후 30분 안에 회원가입 또는 비밀번호 재설정을 마쳐야 한다. + + 불일치·만료·시도 횟수 초과를 구분하지 않고 같은 코드로 응답한다. + """ + ) + @SecurityRequirements + @PostMapping("/email/verification-codes/confirm") + public ApiResponse confirmEmailVerificationCode( + @Valid @RequestBody EmailVerificationConfirmRequest request + ) { + EmailVerificationConfirmResponse response = emailVerificationService.confirm( + request.email(), request.code(), request.purpose() + ); + + return ApiResponse.success(CommonSuccessCode.OK, response); + } + @Operation(summary = "로그아웃", description = "서버에 저장된 리프레시 토큰을 무효화하고 쿠키를 삭제한다.") @PostMapping("/logout") public ResponseEntity> logout( diff --git a/src/main/java/com/slatto/domain/auth/dto/EmailVerificationConfirmRequest.java b/src/main/java/com/slatto/domain/auth/dto/EmailVerificationConfirmRequest.java new file mode 100644 index 00000000..62cced02 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/dto/EmailVerificationConfirmRequest.java @@ -0,0 +1,22 @@ +package com.slatto.domain.auth.dto; + +import com.slatto.domain.auth.enums.VerificationPurpose; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +public record EmailVerificationConfirmRequest( + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + + @NotBlank(message = "인증번호는 필수입니다.") + @Pattern(regexp = "^\\d{6}$", message = "인증번호는 6자리 숫자입니다.") + String code, + + @NotNull(message = "인증 용도는 필수입니다.") + VerificationPurpose purpose +) { +} diff --git a/src/main/java/com/slatto/domain/auth/dto/EmailVerificationConfirmResponse.java b/src/main/java/com/slatto/domain/auth/dto/EmailVerificationConfirmResponse.java new file mode 100644 index 00000000..beb4e5a1 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/dto/EmailVerificationConfirmResponse.java @@ -0,0 +1,6 @@ +package com.slatto.domain.auth.dto; + +import java.time.LocalDateTime; + +public record EmailVerificationConfirmResponse(LocalDateTime verifiedUntil) { +} diff --git a/src/main/java/com/slatto/domain/auth/dto/EmailVerificationSendRequest.java b/src/main/java/com/slatto/domain/auth/dto/EmailVerificationSendRequest.java new file mode 100644 index 00000000..94258d07 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/dto/EmailVerificationSendRequest.java @@ -0,0 +1,19 @@ +package com.slatto.domain.auth.dto; + +import com.slatto.domain.auth.enums.VerificationPurpose; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record EmailVerificationSendRequest( + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + @Size(max = 255, message = "이메일은 255자 이하로 입력해야 합니다.") + String email, + + @NotNull(message = "인증 용도는 필수입니다.") + VerificationPurpose purpose +) { +} diff --git a/src/main/java/com/slatto/domain/auth/dto/EmailVerificationSendResponse.java b/src/main/java/com/slatto/domain/auth/dto/EmailVerificationSendResponse.java new file mode 100644 index 00000000..61c9e2e1 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/dto/EmailVerificationSendResponse.java @@ -0,0 +1,9 @@ +package com.slatto.domain.auth.dto; + +import java.time.LocalDateTime; + +public record EmailVerificationSendResponse( + LocalDateTime expiresAt, + LocalDateTime resendAvailableAt +) { +} diff --git a/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java b/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java index 6d401afb..39f09b6f 100644 --- a/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java +++ b/src/main/java/com/slatto/domain/auth/exception/AuthErrorCode.java @@ -9,7 +9,18 @@ @RequiredArgsConstructor public enum AuthErrorCode implements BaseCode { - INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH401", "리프레시 토큰이 만료되었거나 유효하지 않습니다."); + INVALID_REFRESH_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH401", "리프레시 토큰이 만료되었거나 유효하지 않습니다."), + + // 인증번호 실패 사유(불일치·만료·시도 초과)를 구분하지 않는다. 세분화하면 공격자에게 힌트가 된다. + INVALID_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "AUTH_VERIFICATION_CODE400", "인증번호가 올바르지 않거나 만료되었습니다."), + VERIFICATION_RESEND_TOO_SOON(HttpStatus.TOO_MANY_REQUESTS, "AUTH_VERIFICATION_RESEND429", "잠시 후 다시 시도해 주세요."), + VERIFICATION_SEND_LIMIT_EXCEEDED(HttpStatus.TOO_MANY_REQUESTS, "AUTH_VERIFICATION_LIMIT429", "인증번호 발송 횟수를 초과했습니다. 1시간 후 다시 시도해 주세요."), + EMAIL_NOT_VERIFIED(HttpStatus.BAD_REQUEST, "AUTH_EMAIL_NOT_VERIFIED400", "이메일 인증이 완료되지 않았습니다."), + SIGNUP_DUPLICATE_EMAIL(HttpStatus.CONFLICT, "AUTH_SIGNUP_DUPLICATE409", "이미 가입된 이메일입니다."), + SIGNUP_SOCIAL_ACCOUNT_EXISTS(HttpStatus.CONFLICT, "AUTH_SIGNUP_SOCIAL409", "구글 계정으로 가입된 이메일입니다. 구글 로그인을 이용해 주세요."), + + // 이메일 미존재·비밀번호 불일치·소셜 전용 계정을 모두 같은 응답으로 처리한다. 이메일 열거 방지다. + LOGIN_FAILED(HttpStatus.UNAUTHORIZED, "AUTH_LOGIN401", "이메일 또는 비밀번호가 올바르지 않습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/com/slatto/domain/auth/service/EmailVerificationService.java b/src/main/java/com/slatto/domain/auth/service/EmailVerificationService.java new file mode 100644 index 00000000..74cd6767 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/service/EmailVerificationService.java @@ -0,0 +1,161 @@ +package com.slatto.domain.auth.service; + +import com.slatto.domain.auth.dto.EmailVerificationConfirmResponse; +import com.slatto.domain.auth.dto.EmailVerificationSendResponse; +import com.slatto.domain.auth.entity.EmailVerification; +import com.slatto.domain.auth.enums.VerificationPurpose; +import com.slatto.domain.auth.exception.AuthErrorCode; +import com.slatto.domain.auth.repository.EmailVerificationRepository; +import com.slatto.domain.user.repository.UserRepository; +import com.slatto.global.config.properties.EmailVerificationProperties; +import com.slatto.global.exception.BaseException; +import com.slatto.global.response.code.CommonErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.HexFormat; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class EmailVerificationService { + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int CODE_BOUND = 1_000_000; + private static final Duration RATE_LIMIT_WINDOW = Duration.ofHours(1); + + private final EmailVerificationRepository emailVerificationRepository; + private final UserRepository userRepository; + private final EmailVerificationProperties properties; + private final VerificationMailSender verificationMailSender; + + @Transactional + public EmailVerificationSendResponse send(String email, VerificationPurpose purpose) { + LocalDateTime now = LocalDateTime.now(); + + // 엔티티를 로딩하기 전에 정리한다. 벌크 삭제를 나중에 부르면 영속 상태가 꼬인다. + // 기준 시각을 한도 집계 구간보다 앞에 둬야 최근 발송 이력이 지워지지 않는다. + emailVerificationRepository.deleteDeadRows(email, now.minus(RATE_LIMIT_WINDOW)); + + emailVerificationRepository.findFirstByEmailAndPurposeOrderByIdDesc(email, purpose) + .ifPresent(latest -> { + if (latest.getCreatedAt().plus(properties.resendCooldown()).isAfter(now)) { + throw new BaseException(AuthErrorCode.VERIFICATION_RESEND_TOO_SOON); + } + latest.invalidate(now); + }); + + long sentInWindow = emailVerificationRepository.countByEmailAndPurposeAndCreatedAtAfter( + email, purpose, now.minus(RATE_LIMIT_WINDOW) + ); + if (sentInWindow >= properties.maxSendPerHour()) { + throw new BaseException(AuthErrorCode.VERIFICATION_SEND_LIMIT_EXCEEDED); + } + + String code = generateCode(); + LocalDateTime expiresAt = now.plus(properties.codeValidity()); + emailVerificationRepository.save(EmailVerification.issue(email, purpose, hash(code), expiresAt)); + + // 계정이 없는 비밀번호 재설정 요청은 메일만 보내지 않는다. 응답은 동일하게 성공이다. + // 여기서 실패를 알리면 아무 이메일이나 넣어보고 가입 여부를 알아낼 수 있다. + if (shouldDeliver(email, purpose)) { + sendAfterCommit(email, purpose, code); + } + + return new EmailVerificationSendResponse(expiresAt, now.plus(properties.resendCooldown())); + } + + // 실패해도 시도 횟수 증가와 코드 무효화는 남아야 한다. 롤백되면 무제한 대입이 가능해진다. + @Transactional(noRollbackFor = BaseException.class) + public EmailVerificationConfirmResponse confirm(String email, String code, VerificationPurpose purpose) { + LocalDateTime now = LocalDateTime.now(); + + EmailVerification verification = emailVerificationRepository + .findFirstByEmailAndPurposeOrderByIdDesc(email, purpose) + .orElseThrow(() -> new BaseException(AuthErrorCode.INVALID_VERIFICATION_CODE)); + + // 이미 인증이 끝난 코드로 다시 눌러도 같은 결과를 준다. + if (verification.isVerificationAlive(now, properties.verifiedValidity())) { + return new EmailVerificationConfirmResponse(verification.verifiedUntil(properties.verifiedValidity())); + } + + if (verification.isExpired(now) + || verification.isConsumed() + || !verification.hasAttemptsLeft(properties.maxAttempts())) { + throw new BaseException(AuthErrorCode.INVALID_VERIFICATION_CODE); + } + + verification.increaseAttemptCount(); + + if (!verification.matches(hash(code))) { + if (!verification.hasAttemptsLeft(properties.maxAttempts())) { + verification.invalidate(now); + } + throw new BaseException(AuthErrorCode.INVALID_VERIFICATION_CODE); + } + + verification.markVerified(now); + + return new EmailVerificationConfirmResponse(verification.verifiedUntil(properties.verifiedValidity())); + } + + // 회원가입·비밀번호 재설정이 인증을 소진한다. 같은 인증으로 두 번 처리되지 않도록 막는다. + @Transactional + public void consumeVerified(String email, VerificationPurpose purpose) { + LocalDateTime now = LocalDateTime.now(); + + EmailVerification verification = emailVerificationRepository + .findFirstByEmailAndPurposeOrderByIdDesc(email, purpose) + .filter(it -> it.isVerificationAlive(now, properties.verifiedValidity())) + .orElseThrow(() -> new BaseException(AuthErrorCode.EMAIL_NOT_VERIFIED)); + + verification.markConsumed(now); + } + + private boolean shouldDeliver(String email, VerificationPurpose purpose) { + if (purpose != VerificationPurpose.PASSWORD_RESET) { + return true; + } + + return userRepository.findByEmail(email).isPresent(); + } + + // 커밋 전에 보내면 롤백된 인증번호가 사용자에게 도착한다. 입력해도 실패하는 코드다. + private void sendAfterCommit(String email, VerificationPurpose purpose, String code) { + if (!TransactionSynchronizationManager.isSynchronizationActive()) { + verificationMailSender.sendVerificationCode(email, purpose, code); + return; + } + + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + verificationMailSender.sendVerificationCode(email, purpose, code); + } + }); + } + + private String generateCode() { + return "%06d".formatted(SECURE_RANDOM.nextInt(CODE_BOUND)); + } + + private String hash(String code) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + + return HexFormat.of().formatHex(digest.digest(code.getBytes(StandardCharsets.UTF_8))); + } catch (NoSuchAlgorithmException exception) { + throw new BaseException(CommonErrorCode.INTERNAL_SERVER_ERROR); + } + } + +} diff --git a/src/main/java/com/slatto/domain/auth/service/VerificationMailSender.java b/src/main/java/com/slatto/domain/auth/service/VerificationMailSender.java new file mode 100644 index 00000000..f04eb406 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/service/VerificationMailSender.java @@ -0,0 +1,69 @@ +package com.slatto.domain.auth.service; + +import com.slatto.domain.auth.enums.VerificationPurpose; +import com.slatto.global.config.AsyncConfig; +import com.slatto.global.config.properties.MailSenderProperties; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; + +@Slf4j +@Component +@RequiredArgsConstructor +public class VerificationMailSender { + + private final JavaMailSender javaMailSender; + private final MailSenderProperties mailSenderProperties; + + // 비동기라 API 는 이미 성공 응답을 보낸 뒤다. 실패를 사용자에게 알릴 수단이 없으므로 + // 로그만 남기고 재발송으로 갈음한다. 예외를 던져도 받아줄 곳이 없다. + @Async(AsyncConfig.MAIL_EXECUTOR) + public void sendVerificationCode(String email, VerificationPurpose purpose, String code) { + try { + MimeMessage message = javaMailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, false, StandardCharsets.UTF_8.name()); + + helper.setFrom(new InternetAddress( + mailSenderProperties.fromAddress(), + mailSenderProperties.fromName(), + StandardCharsets.UTF_8.name() + )); + helper.setTo(email); + helper.setSubject(resolveSubject(purpose)); + helper.setText(resolveBody(purpose, code), false); + + javaMailSender.send(message); + } catch (Exception exception) { + log.warn("[Mail] 인증번호 발송 실패. email={}, purpose={}", email, purpose, exception); + } + } + + private String resolveSubject(VerificationPurpose purpose) { + return purpose == VerificationPurpose.PASSWORD_RESET + ? "[슬레이투] 비밀번호 재설정 인증번호" + : "[슬레이투] 회원가입 인증번호"; + } + + private String resolveBody(VerificationPurpose purpose, String code) { + String action = purpose == VerificationPurpose.PASSWORD_RESET ? "비밀번호 재설정" : "회원가입"; + + return """ + 안녕하세요, 슬레이투입니다. + + %s을 위한 인증번호는 다음과 같습니다. + + %s + + 인증번호는 5분간 유효합니다. + 본인이 요청하지 않았다면 이 메일을 무시해 주세요. + """.formatted(action, code); + } + +} From 70a85e4079aecd1a28f616048c3f2059b94955ca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Thu, 6 Aug 2026 16:33:21 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=EA=B0=80=EC=9E=85=C2=B7=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=EC=9D=B8=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /auth/signup, /auth/login 추가 - 액세스 토큰은 본문, 리프레시 토큰은 HttpOnly 쿠키로 전달 - 회원가입은 인증 확인을 중복 검사보다 먼저 해 이메일 열거를 막는다 - 이미 가입된 이메일과 소셜 전용 계정을 다른 코드로 구분해 프론트가 분기할 수 있게 한다 - 로그인 실패는 원인을 구분하지 않고 계정 미존재에도 해시 비교를 수행해 응답 시간을 맞춘다 --- .../auth/controller/AuthController.java | 60 ++++++++++++++++ .../domain/auth/dto/EmailAuthResponse.java | 8 +++ .../domain/auth/dto/EmailLoginRequest.java | 15 ++++ .../domain/auth/dto/EmailSignupRequest.java | 26 +++++++ .../domain/auth/service/AuthService.java | 69 +++++++++++++++++++ 5 files changed, 178 insertions(+) create mode 100644 src/main/java/com/slatto/domain/auth/dto/EmailAuthResponse.java create mode 100644 src/main/java/com/slatto/domain/auth/dto/EmailLoginRequest.java create mode 100644 src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java diff --git a/src/main/java/com/slatto/domain/auth/controller/AuthController.java b/src/main/java/com/slatto/domain/auth/controller/AuthController.java index 3d41d08e..e45c5bfe 100644 --- a/src/main/java/com/slatto/domain/auth/controller/AuthController.java +++ b/src/main/java/com/slatto/domain/auth/controller/AuthController.java @@ -1,6 +1,9 @@ package com.slatto.domain.auth.controller; import com.slatto.domain.auth.dto.AccessTokenResponse; +import com.slatto.domain.auth.dto.EmailAuthResponse; +import com.slatto.domain.auth.dto.EmailLoginRequest; +import com.slatto.domain.auth.dto.EmailSignupRequest; import com.slatto.domain.auth.dto.EmailVerificationConfirmRequest; import com.slatto.domain.auth.dto.EmailVerificationConfirmResponse; import com.slatto.domain.auth.dto.EmailVerificationSendRequest; @@ -103,6 +106,53 @@ public ApiResponse reissueAccessToken( return ApiResponse.success(CommonSuccessCode.OK, authService.reissueAccessToken(refreshToken)); } + @Operation( + summary = "이메일 회원가입", + description = """ + 이메일 인증을 마친 사용자의 계정을 생성하고 즉시 로그인 상태로 만든다. + 액세스 토큰은 본문으로, 리프레시 토큰은 HttpOnly 쿠키로 내려간다. + + `onboardingCompleted`는 항상 `false`다. 이어서 온보딩 화면으로 이동한다. + 약관 동의는 이 API 가 아니라 온보딩 API 가 받는다. + """ + ) + @SecurityRequirements + @PostMapping("/signup") + public ResponseEntity> signup( + @Valid @RequestBody EmailSignupRequest request + ) { + AuthService.EmailAuthResult result = authService.signup( + request.name(), request.email(), request.password() + ); + + return ResponseEntity + .status(HttpStatus.CREATED) + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie(result)) + .body(ApiResponse.success(CommonSuccessCode.CREATED, toEmailAuthResponse(result))); + } + + @Operation( + summary = "이메일 로그인", + description = """ + 이메일과 비밀번호로 로그인한다. + 액세스 토큰은 본문으로, 리프레시 토큰은 HttpOnly 쿠키로 내려간다. + + 이메일 미존재·비밀번호 불일치·소셜 전용 계정을 구분하지 않고 모두 같은 401 을 반환한다. + """ + ) + @SecurityRequirements + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody EmailLoginRequest request + ) { + AuthService.EmailAuthResult result = authService.login(request.email(), request.password()); + + return ResponseEntity + .ok() + .header(HttpHeaders.SET_COOKIE, refreshTokenCookie(result)) + .body(ApiResponse.success(CommonSuccessCode.OK, toEmailAuthResponse(result))); + } + @Operation( summary = "이메일 인증번호 발송", description = """ @@ -159,4 +209,14 @@ public ResponseEntity> logout( .body(ApiResponse.success(CommonSuccessCode.OK, null)); } + private String refreshTokenCookie(AuthService.EmailAuthResult result) { + return authCookieFactory + .refreshToken(result.refreshToken(), result.refreshTokenMaxAgeSeconds()) + .toString(); + } + + private EmailAuthResponse toEmailAuthResponse(AuthService.EmailAuthResult result) { + return new EmailAuthResponse(result.userId(), result.accessToken(), result.onboardingCompleted()); + } + } diff --git a/src/main/java/com/slatto/domain/auth/dto/EmailAuthResponse.java b/src/main/java/com/slatto/domain/auth/dto/EmailAuthResponse.java new file mode 100644 index 00000000..4303ef29 --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/dto/EmailAuthResponse.java @@ -0,0 +1,8 @@ +package com.slatto.domain.auth.dto; + +public record EmailAuthResponse( + Long userId, + String accessToken, + Boolean onboardingCompleted +) { +} diff --git a/src/main/java/com/slatto/domain/auth/dto/EmailLoginRequest.java b/src/main/java/com/slatto/domain/auth/dto/EmailLoginRequest.java new file mode 100644 index 00000000..b7b0aa1e --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/dto/EmailLoginRequest.java @@ -0,0 +1,15 @@ +package com.slatto.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record EmailLoginRequest( + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + + @NotBlank(message = "비밀번호는 필수입니다.") + String password +) { +} diff --git a/src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java b/src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java new file mode 100644 index 00000000..6689d47d --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/dto/EmailSignupRequest.java @@ -0,0 +1,26 @@ +package com.slatto.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record EmailSignupRequest( + + @NotBlank(message = "이름은 필수입니다.") + @Size(min = 1, max = 20, message = "이름은 1자 이상 20자 이하로 입력해야 합니다.") + String name, + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + @Size(max = 255, message = "이메일은 255자 이하로 입력해야 합니다.") + String email, + + @NotBlank(message = "비밀번호는 필수입니다.") + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{8,64}$", + message = "비밀번호는 영문·숫자·특수문자를 포함해 8자 이상 64자 이하로 입력해야 합니다." + ) + String password +) { +} diff --git a/src/main/java/com/slatto/domain/auth/service/AuthService.java b/src/main/java/com/slatto/domain/auth/service/AuthService.java index 3d7441d2..b7b96afb 100644 --- a/src/main/java/com/slatto/domain/auth/service/AuthService.java +++ b/src/main/java/com/slatto/domain/auth/service/AuthService.java @@ -5,6 +5,7 @@ import com.slatto.domain.auth.client.dto.GoogleUserInfo; import com.slatto.domain.auth.dto.AccessTokenResponse; import com.slatto.domain.auth.entity.RefreshToken; +import com.slatto.domain.auth.enums.VerificationPurpose; import com.slatto.domain.auth.exception.AuthErrorCode; import com.slatto.domain.auth.repository.RefreshTokenRepository; import com.slatto.domain.auth.support.GoogleAuthFailureReason; @@ -19,6 +20,7 @@ import com.slatto.global.security.JwtTokenProvider; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -30,10 +32,17 @@ @Transactional(readOnly = true) public class AuthService { + // 존재하지 않는 이메일이면 해시 비교를 건너뛰어 응답이 빨라진다. 그 시간 차이만으로 가입 여부가 드러나므로 + // 항상 한 번은 비교한다. 이 값은 결과가 버려지는 자리에만 쓴다. + private static final String DUMMY_PASSWORD_HASH = + "$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy"; + private final GoogleOAuthClient googleOAuthClient; private final UserRepository userRepository; private final RefreshTokenRepository refreshTokenRepository; private final NotificationSettingRepository notificationSettingRepository; + private final EmailVerificationService emailVerificationService; + private final PasswordEncoder passwordEncoder; private final JwtTokenProvider jwtTokenProvider; private final FrontendProperties frontendProperties; @@ -84,6 +93,47 @@ public GoogleCallbackResult handleGoogleCallback( ); } + // 인증 확인을 중복 검사보다 먼저 한다. 순서를 뒤집으면 인증 없이 아무 이메일이나 넣어보고 + // 409 인지 400 인지로 가입 여부를 알아낼 수 있다. + @Transactional + public EmailAuthResult signup(String name, String email, String rawPassword) { + emailVerificationService.consumeVerified(email, VerificationPurpose.SIGNUP); + + userRepository.findByEmail(email).ifPresent(existing -> { + throw new BaseException(existing.hasPassword() + ? AuthErrorCode.SIGNUP_DUPLICATE_EMAIL + : AuthErrorCode.SIGNUP_SOCIAL_ACCOUNT_EXISTS); + }); + + Users user = userRepository.save( + Users.createEmailUser(email, name, passwordEncoder.encode(rawPassword)) + ); + notificationSettingRepository.save(NotificationSetting.createDefault(user)); + + return toEmailAuthResult(user); + } + + @Transactional + public EmailAuthResult login(String email, String rawPassword) { + Users user = userRepository.findByEmail(email) + .filter(it -> it.getDeletedAt() == null) + .orElse(null); + + boolean hasPassword = user != null && user.hasPassword(); + boolean matched = passwordEncoder.matches( + rawPassword, + hasPassword ? user.getPassword() : DUMMY_PASSWORD_HASH + ); + + // 미존재·비밀번호 불일치·소셜 전용 계정을 구분하지 않는다. + // "구글로 가입된 계정입니다" 같은 안내는 이메일 열거를 그대로 허용한다. + if (!hasPassword || !matched) { + throw new BaseException(AuthErrorCode.LOGIN_FAILED); + } + + return toEmailAuthResult(user); + } + // TODO: 리프레시 토큰 회전(rotation) 도입 시 여기서 기존 토큰을 폐기하고 새 토큰을 발급해 // AccessTokenResponse와 함께 Set-Cookie로 다시 내려줘야 한다. @Transactional(readOnly = true) @@ -131,6 +181,16 @@ private Users findOrCreateUser(GoogleUserInfo userInfo) { }); } + private EmailAuthResult toEmailAuthResult(Users user) { + return new EmailAuthResult( + user.getId(), + jwtTokenProvider.createAccessToken(user.getId()), + user.getOnboardingCompleted(), + issueRefreshToken(user), + jwtTokenProvider.refreshTokenMaxAgeSeconds() + ); + } + private String issueRefreshToken(Users user) { refreshTokenRepository.deleteByUser(user); @@ -150,6 +210,15 @@ private GoogleCallbackResult failure(GoogleAuthFailureReason reason) { public record GoogleLoginEntry(String authorizationUri, OAuthState state) { } + public record EmailAuthResult( + Long userId, + String accessToken, + Boolean onboardingCompleted, + String refreshToken, + long refreshTokenMaxAgeSeconds + ) { + } + public record GoogleCallbackResult(String redirectUri, String refreshToken, long refreshTokenMaxAgeSeconds) { public boolean isSuccess() { From df76f60a5980f524dbc7f3542998eb261162f792 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Thu, 6 Aug 2026 16:36:25 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=AC=EC=84=A4=EC=A0=95=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - POST /auth/password/reset 추가 - PASSWORD_RESET 인증을 소진해야 통과하며 소셜 전용 계정도 비밀번호를 설정할 수 있다 - 재설정 성공 시 해당 유저의 리프레시 토큰을 전부 삭제해 기존 세션을 끊는다 --- .../auth/controller/AuthController.java | 18 +++++++++++++++++ .../domain/auth/dto/PasswordResetRequest.java | 20 +++++++++++++++++++ .../domain/auth/service/AuthService.java | 15 ++++++++++++++ 3 files changed, 53 insertions(+) create mode 100644 src/main/java/com/slatto/domain/auth/dto/PasswordResetRequest.java diff --git a/src/main/java/com/slatto/domain/auth/controller/AuthController.java b/src/main/java/com/slatto/domain/auth/controller/AuthController.java index e45c5bfe..aa678898 100644 --- a/src/main/java/com/slatto/domain/auth/controller/AuthController.java +++ b/src/main/java/com/slatto/domain/auth/controller/AuthController.java @@ -8,6 +8,7 @@ import com.slatto.domain.auth.dto.EmailVerificationConfirmResponse; import com.slatto.domain.auth.dto.EmailVerificationSendRequest; import com.slatto.domain.auth.dto.EmailVerificationSendResponse; +import com.slatto.domain.auth.dto.PasswordResetRequest; import com.slatto.domain.auth.service.AuthService; import com.slatto.domain.auth.service.EmailVerificationService; import com.slatto.domain.auth.support.AuthCookieFactory; @@ -196,6 +197,23 @@ public ApiResponse confirmEmailVerificationCod return ApiResponse.success(CommonSuccessCode.OK, response); } + @Operation( + summary = "비밀번호 재설정", + description = """ + `PASSWORD_RESET` 목적의 이메일 인증을 마친 사용자의 비밀번호를 변경한다. + + 성공하면 해당 유저의 리프레시 토큰을 전부 삭제해 기존 세션을 끊는다. + 구글로만 가입해 비밀번호가 없던 계정도 이 경로로 비밀번호를 설정할 수 있다. + """ + ) + @SecurityRequirements + @PostMapping("/password/reset") + public ApiResponse resetPassword(@Valid @RequestBody PasswordResetRequest request) { + authService.resetPassword(request.email(), request.newPassword()); + + return ApiResponse.success(CommonSuccessCode.OK, null); + } + @Operation(summary = "로그아웃", description = "서버에 저장된 리프레시 토큰을 무효화하고 쿠키를 삭제한다.") @PostMapping("/logout") public ResponseEntity> logout( diff --git a/src/main/java/com/slatto/domain/auth/dto/PasswordResetRequest.java b/src/main/java/com/slatto/domain/auth/dto/PasswordResetRequest.java new file mode 100644 index 00000000..fd28af8a --- /dev/null +++ b/src/main/java/com/slatto/domain/auth/dto/PasswordResetRequest.java @@ -0,0 +1,20 @@ +package com.slatto.domain.auth.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record PasswordResetRequest( + + @NotBlank(message = "이메일은 필수입니다.") + @Email(message = "이메일 형식이 올바르지 않습니다.") + String email, + + @NotBlank(message = "새 비밀번호는 필수입니다.") + @Pattern( + regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[^A-Za-z0-9]).{8,64}$", + message = "비밀번호는 영문·숫자·특수문자를 포함해 8자 이상 64자 이하로 입력해야 합니다." + ) + String newPassword +) { +} diff --git a/src/main/java/com/slatto/domain/auth/service/AuthService.java b/src/main/java/com/slatto/domain/auth/service/AuthService.java index b7b96afb..71d49c90 100644 --- a/src/main/java/com/slatto/domain/auth/service/AuthService.java +++ b/src/main/java/com/slatto/domain/auth/service/AuthService.java @@ -134,6 +134,21 @@ public EmailAuthResult login(String email, String rawPassword) { return toEmailAuthResult(user); } + @Transactional + public void resetPassword(String email, String newRawPassword) { + emailVerificationService.consumeVerified(email, VerificationPurpose.PASSWORD_RESET); + + // 계정이 없으면 인증 메일 자체가 나가지 않으므로 여기까지 올 수 없다. 방어적으로 같은 코드를 쓴다. + Users user = userRepository.findByEmail(email) + .filter(it -> it.getDeletedAt() == null) + .orElseThrow(() -> new BaseException(AuthErrorCode.EMAIL_NOT_VERIFIED)); + + user.changePassword(passwordEncoder.encode(newRawPassword)); + + // 비밀번호가 유출돼 재설정하는 상황을 가정한다. 살아 있는 세션을 끊지 않으면 의미가 없다. + refreshTokenRepository.deleteByUser(user); + } + // TODO: 리프레시 토큰 회전(rotation) 도입 시 여기서 기존 토큰을 폐기하고 새 토큰을 발급해 // AccessTokenResponse와 함께 Set-Cookie로 다시 내려줘야 한다. @Transactional(readOnly = true) From 4a2070e3b15f15d83955a04c162982ce18b4e5c5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Thu, 6 Aug 2026 17:01:47 +0900 Subject: [PATCH 6/8] =?UTF-8?q?test:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=EB=B2=88=ED=98=B8=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 재발송 쿨다운, 시도 횟수 유지, 시도 소진 시 무효화, 미인증 소진 거부 검증 - 확인 실패가 롤백되면 시도 횟수가 사라져 무제한 대입이 가능해지므로 이를 회귀 테스트로 고정 - 테스트 메일 설정에 짧은 타임아웃을 넣어 실수로 발송이 일어나도 즉시 실패하게 한다 --- .../service/EmailVerificationServiceTest.java | 116 ++++++++++++++++++ src/test/resources/application.yml | 5 + 2 files changed, 121 insertions(+) create mode 100644 src/test/java/com/slatto/domain/auth/service/EmailVerificationServiceTest.java diff --git a/src/test/java/com/slatto/domain/auth/service/EmailVerificationServiceTest.java b/src/test/java/com/slatto/domain/auth/service/EmailVerificationServiceTest.java new file mode 100644 index 00000000..f3087388 --- /dev/null +++ b/src/test/java/com/slatto/domain/auth/service/EmailVerificationServiceTest.java @@ -0,0 +1,116 @@ +package com.slatto.domain.auth.service; + +import com.slatto.domain.auth.entity.EmailVerification; +import com.slatto.domain.auth.enums.VerificationPurpose; +import com.slatto.domain.auth.exception.AuthErrorCode; +import com.slatto.domain.auth.repository.EmailVerificationRepository; +import com.slatto.global.config.properties.EmailVerificationProperties; +import com.slatto.global.exception.BaseException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DataJpaTest +@Import(EmailVerificationService.class) +@EnableConfigurationProperties(EmailVerificationProperties.class) +@TestPropertySource(properties = { + "spring.jpa.database=h2", + "spring.jpa.database-platform=org.hibernate.dialect.H2Dialect", + "spring.jpa.hibernate.ddl-auto=create-drop", + "app.email-verification.code-validity=PT5M", + "app.email-verification.resend-cooldown=PT1M", + "app.email-verification.verified-validity=PT30M", + "app.email-verification.max-send-per-hour=5", + "app.email-verification.max-attempts=5" +}) +class EmailVerificationServiceTest { + + private static final String EMAIL = "tester@slatto.com"; + private static final String WRONG_CODE = "000000"; + private static final int MAX_ATTEMPTS = 5; + + // 실제 SMTP 발송을 붙이지 않는다. 발송 자체는 커밋 이후에 일어나 이 슬라이스에서는 검증 대상이 아니다. + @MockitoBean + private VerificationMailSender verificationMailSender; + + @Autowired + private EmailVerificationService emailVerificationService; + + @Autowired + private EmailVerificationRepository emailVerificationRepository; + + @Test + @DisplayName("재발송 쿨다운 안에 다시 요청하면 거부한다") + void rejectsResendWithinCooldown() { + emailVerificationService.send(EMAIL, VerificationPurpose.SIGNUP); + + assertThatThrownBy(() -> emailVerificationService.send(EMAIL, VerificationPurpose.SIGNUP)) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(AuthErrorCode.VERIFICATION_RESEND_TOO_SOON); + } + + // 확인 실패는 예외를 던지지만 시도 횟수는 남아야 한다. 롤백되면 무제한 대입이 가능해진다. + @Test + @DisplayName("인증번호가 틀리면 실패해도 시도 횟수가 남는다") + void keepsAttemptCountOnFailure() { + emailVerificationService.send(EMAIL, VerificationPurpose.SIGNUP); + + assertThatThrownBy(() -> emailVerificationService.confirm(EMAIL, WRONG_CODE, VerificationPurpose.SIGNUP)) + .isInstanceOf(BaseException.class); + + assertThat(latest().getAttemptCount()).isEqualTo(1); + } + + @Test + @DisplayName("시도 횟수를 모두 소진하면 코드가 무효화된다") + void invalidatesCodeAfterMaxAttempts() { + emailVerificationService.send(EMAIL, VerificationPurpose.SIGNUP); + + for (int i = 0; i < MAX_ATTEMPTS; i++) { + assertThatThrownBy(() -> emailVerificationService.confirm(EMAIL, WRONG_CODE, VerificationPurpose.SIGNUP)) + .isInstanceOf(BaseException.class); + } + + EmailVerification verification = latest(); + assertThat(verification.getAttemptCount()).isEqualTo(MAX_ATTEMPTS); + assertThat(verification.isExpired(LocalDateTime.now())).isTrue(); + } + + @Test + @DisplayName("발송 이력이 없는 이메일의 인증번호 확인은 실패한다") + void rejectsConfirmWithoutSentCode() { + assertThatThrownBy(() -> emailVerificationService.confirm(EMAIL, WRONG_CODE, VerificationPurpose.SIGNUP)) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(AuthErrorCode.INVALID_VERIFICATION_CODE); + } + + @Test + @DisplayName("인증을 마치지 않은 이메일은 소진할 수 없다") + void rejectsConsumeWithoutVerification() { + emailVerificationService.send(EMAIL, VerificationPurpose.SIGNUP); + + assertThatThrownBy(() -> emailVerificationService.consumeVerified(EMAIL, VerificationPurpose.SIGNUP)) + .isInstanceOf(BaseException.class) + .extracting(exception -> ((BaseException) exception).getErrorCode()) + .isEqualTo(AuthErrorCode.EMAIL_NOT_VERIFIED); + } + + private EmailVerification latest() { + return emailVerificationRepository + .findFirstByEmailAndPurposeOrderByIdDesc(EMAIL, VerificationPurpose.SIGNUP) + .orElseThrow(); + } + +} diff --git a/src/test/resources/application.yml b/src/test/resources/application.yml index bceb3c62..616053ca 100644 --- a/src/test/resources/application.yml +++ b/src/test/resources/application.yml @@ -24,6 +24,11 @@ spring: port: 3025 username: test@example.com password: test-password + properties: + # 테스트에서 실수로 실제 발송이 일어나도 즉시 실패하게 한다. 없으면 OS 기본 타임아웃까지 블록된다. + mail.smtp.connectiontimeout: 500 + mail.smtp.timeout: 500 + mail.smtp.writetimeout: 500 cloud: aws: From cd0f97672b1c21e03f61f356494a2f0334f82e17 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Fri, 7 Aug 2026 11:50:47 +0900 Subject: [PATCH 7/8] =?UTF-8?q?chore:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20=EB=B2=88=ED=98=B8=EB=A5=BC=20V013=20?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V012 를 사용하는 project_file 레거시 컬럼 제거 작업이 먼저 배포될 예정이다. Flyway 는 outOfOrder 가 기본 false 라 이미 적용된 버전보다 낮은 마이그레이션을 거부하므로, 뒤에 배포되는 쪽이 더 높은 번호를 가져간다. --- ...{V011__email_verification.sql => V013__email_verification.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/main/resources/db/migration/{V011__email_verification.sql => V013__email_verification.sql} (100%) diff --git a/src/main/resources/db/migration/V011__email_verification.sql b/src/main/resources/db/migration/V013__email_verification.sql similarity index 100% rename from src/main/resources/db/migration/V011__email_verification.sql rename to src/main/resources/db/migration/V013__email_verification.sql From 9c519be4fba8b6e74b55401420e668daf79cf102 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9D=B4=EC=83=81=EC=9B=90?= Date: Fri, 7 Aug 2026 12:47:28 +0900 Subject: [PATCH 8/8] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EC=A7=80=EC=A0=81=20=EC=82=AC=ED=95=AD=20=EB=B0=98?= =?UTF-8?q?=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 만료 행 정리에서 인증 여부 조건을 제거해 인증만 하고 가입하지 않은 행이 영구히 남던 문제를 수정 - 비밀번호 재설정 메일 발송 대상에서 탈퇴 계정을 제외해 resetPassword 조건과 일치시킴 - 메일 큐 포화 시 호출 스레드에서 SMTP 를 실행하지 않고 버린다. 공개 엔드포인트라 반복 호출로 요청 스레드가 묶일 수 있고, 발송 실패는 재발송으로 갈음하는 정책과도 맞다 - 발송 실패 로그의 이메일을 마스킹 --- .../repository/EmailVerificationRepository.java | 4 ++-- .../auth/service/EmailVerificationService.java | 6 +++++- .../auth/service/VerificationMailSender.java | 17 ++++++++++++++++- .../com/slatto/global/config/AsyncConfig.java | 10 +++++++--- 4 files changed, 30 insertions(+), 7 deletions(-) diff --git a/src/main/java/com/slatto/domain/auth/repository/EmailVerificationRepository.java b/src/main/java/com/slatto/domain/auth/repository/EmailVerificationRepository.java index 0aeef554..fd4879b9 100644 --- a/src/main/java/com/slatto/domain/auth/repository/EmailVerificationRepository.java +++ b/src/main/java/com/slatto/domain/auth/repository/EmailVerificationRepository.java @@ -27,15 +27,15 @@ long countByEmailAndPurposeAndCreatedAtAfter( ); // 별도 정리 스케줄러를 두지 않는다. 새 인증번호를 발송할 때 같은 이메일의 죽은 행을 함께 지운다. - // 아직 쓸 수 있는 인증(verified 유효 구간)은 남겨야 하므로 consumed 이거나 인증 전인 행만 대상으로 한다. // threshold 는 시간당 한도 집계 구간보다 앞서야 한다. 최근 1시간 행을 지우면 발송 횟수가 리셋된다. + // 그 시점이면 인증 유효 시간(30분)도 이미 지났으므로 인증 여부로 거르지 않는다. + // 거르면 인증만 하고 가입하지 않은 행이 영구히 남는다. // clearAutomatically 를 쓰지 않는다. 영속 엔티티를 detach 시켜 이후 조회가 꼬인다. @Modifying @Query(""" delete from EmailVerification ev where ev.email = :email and ev.expiresAt < :threshold - and (ev.verifiedAt is null or ev.consumedAt is not null) """) int deleteDeadRows( @Param("email") String email, diff --git a/src/main/java/com/slatto/domain/auth/service/EmailVerificationService.java b/src/main/java/com/slatto/domain/auth/service/EmailVerificationService.java index 74cd6767..e48d30c8 100644 --- a/src/main/java/com/slatto/domain/auth/service/EmailVerificationService.java +++ b/src/main/java/com/slatto/domain/auth/service/EmailVerificationService.java @@ -121,12 +121,16 @@ public void consumeVerified(String email, VerificationPurpose purpose) { verification.markConsumed(now); } + // 재설정 대상 조건을 AuthService.resetPassword 와 같게 맞춘다. + // 탈퇴 계정에 코드를 보내면 인증까지 통과한 뒤 마지막 단계에서만 실패한다. private boolean shouldDeliver(String email, VerificationPurpose purpose) { if (purpose != VerificationPurpose.PASSWORD_RESET) { return true; } - return userRepository.findByEmail(email).isPresent(); + return userRepository.findByEmail(email) + .filter(user -> user.getDeletedAt() == null) + .isPresent(); } // 커밋 전에 보내면 롤백된 인증번호가 사용자에게 도착한다. 입력해도 실패하는 코드다. diff --git a/src/main/java/com/slatto/domain/auth/service/VerificationMailSender.java b/src/main/java/com/slatto/domain/auth/service/VerificationMailSender.java index f04eb406..af4f4703 100644 --- a/src/main/java/com/slatto/domain/auth/service/VerificationMailSender.java +++ b/src/main/java/com/slatto/domain/auth/service/VerificationMailSender.java @@ -41,10 +41,25 @@ public void sendVerificationCode(String email, VerificationPurpose purpose, Stri javaMailSender.send(message); } catch (Exception exception) { - log.warn("[Mail] 인증번호 발송 실패. email={}, purpose={}", email, purpose, exception); + log.warn("[Mail] 인증번호 발송 실패. email={}, purpose={}", maskEmail(email), purpose, exception); } } + // 로그 저장소에 이메일 원본이 쌓이지 않게 한다. 도메인은 남겨 수신처별 실패 경향을 볼 수 있게 한다. + private String maskEmail(String email) { + int atIndex = email == null ? -1 : email.indexOf('@'); + if (atIndex < 1) { + return "***"; + } + + String localPart = email.substring(0, atIndex); + String maskedLocalPart = localPart.length() <= 2 + ? localPart.charAt(0) + "***" + : localPart.substring(0, 2) + "***"; + + return maskedLocalPart + email.substring(atIndex); + } + private String resolveSubject(VerificationPurpose purpose) { return purpose == VerificationPurpose.PASSWORD_RESET ? "[슬레이투] 비밀번호 재설정 인증번호" diff --git a/src/main/java/com/slatto/global/config/AsyncConfig.java b/src/main/java/com/slatto/global/config/AsyncConfig.java index da487c09..05b7b0d2 100644 --- a/src/main/java/com/slatto/global/config/AsyncConfig.java +++ b/src/main/java/com/slatto/global/config/AsyncConfig.java @@ -1,13 +1,14 @@ package com.slatto.global.config; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; import java.util.concurrent.Executor; -import java.util.concurrent.ThreadPoolExecutor; +@Slf4j @Configuration @EnableAsync public class AsyncConfig { @@ -15,7 +16,9 @@ public class AsyncConfig { public static final String MAIL_EXECUTOR = "mailExecutor"; // 메일 전용 풀이다. 공용 풀을 쓰면 SMTP 지연이 다른 비동기 작업까지 함께 막는다. - // 큐가 차면 호출 스레드가 직접 실행한다. 인증번호는 버리는 것보다 늦게라도 나가는 편이 낫다. + // 큐가 차면 버린다. 인증번호 발송은 공개 엔드포인트라 호출 스레드에서 SMTP 를 태우면 + // 반복 호출만으로 요청 스레드가 묶인다. 발송 실패는 사용자의 재발송으로 갈음하는 정책이라 + // 여기서 버리는 것도 같은 처리다. 예외를 던지면 커밋 후 콜백에서 터지므로 로그만 남긴다. @Bean(name = MAIL_EXECUTOR) public Executor mailExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); @@ -23,7 +26,8 @@ public Executor mailExecutor() { executor.setMaxPoolSize(4); executor.setQueueCapacity(100); executor.setThreadNamePrefix("mail-"); - executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); + executor.setRejectedExecutionHandler((rejected, poolExecutor) -> + log.warn("[Mail] 발송 큐가 가득 차 작업을 버렸다. 사용자는 재발송으로 처리한다.")); executor.initialize(); return executor;