Repository navigation
이메일 인증 기능 추가 #40
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
이메일 인증 기능 추가 #40
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| package chainsawman.gesture.dto.user.request; | ||
|
|
||
| import jakarta.validation.constraints.Email; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| public class EmailSendRequest { | ||
|
|
||
| @NotBlank | ||
| private String email; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package chainsawman.gesture.dto.user.request; | ||
|
|
||
| import jakarta.validation.constraints.Email; | ||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.Pattern; | ||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| public class EmailVerificationRequest { | ||
|
|
||
| @NotBlank | ||
| private String email; | ||
|
|
||
| @NotBlank | ||
| @Pattern(regexp = "\\d{6}") | ||
| private String code; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| package chainsawman.gesture.dto.user.response; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonFormat; | ||
| import lombok.Builder; | ||
| import lombok.Getter; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| @Getter | ||
| @Builder | ||
| public class EmailVerificationResponse { | ||
|
|
||
| private boolean verified; | ||
| private String email; | ||
|
|
||
| @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") | ||
| private LocalDateTime verifiedAt; | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,7 +5,9 @@ | |
| import chainsawman.gesture.entity.user.RefreshToken; | ||
| import chainsawman.gesture.entity.user.User; | ||
| import chainsawman.gesture.enums.ProviderType; | ||
| import chainsawman.gesture.exceptions.auth.ExpiredVerificationCodeException; | ||
| import chainsawman.gesture.exceptions.auth.InvalidRefreshTokenException; | ||
| import chainsawman.gesture.exceptions.auth.InvalidVerificationCodeException; | ||
| import chainsawman.gesture.exceptions.user.DeactivatedUserException; | ||
| import chainsawman.gesture.exceptions.user.DuplicateEmailException; | ||
| import chainsawman.gesture.exceptions.user.DuplicateIdException; | ||
|
|
@@ -16,23 +18,32 @@ | |
| import chainsawman.gesture.global.TokenProvider; | ||
| import chainsawman.gesture.repository.user.RefreshTokenRepository; | ||
| import chainsawman.gesture.repository.user.UserRepository; | ||
| import org.springframework.data.redis.core.StringRedisTemplate; | ||
| import org.springframework.transaction.annotation.Transactional; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.security.crypto.password.PasswordEncoder; | ||
| import org.springframework.stereotype.Service; | ||
|
|
||
| import java.security.SecureRandom; | ||
| import java.time.LocalDateTime; | ||
| import java.util.Optional; | ||
| import java.util.concurrent.TimeUnit; | ||
|
|
||
| @Service | ||
| @RequiredArgsConstructor | ||
| @Transactional | ||
| public class AuthService { | ||
| private static final String EMAIL_VERIFICATION_PREFIX = "email:verification:"; | ||
| private static final long VERIFICATION_TTL_MINUTES = 5; | ||
| private static final SecureRandom SECURE_RANDOM = new SecureRandom(); | ||
|
|
||
| private final UserRepository userRepository; | ||
| private final PasswordEncoder passwordEncoder; | ||
| private final TokenProvider tokenProvider; | ||
| private final MediaService mediaService; | ||
| private final RefreshTokenRepository refreshTokenRepository; | ||
| private final StringRedisTemplate redisTemplate; | ||
| private final EmailService emailService; | ||
|
|
||
|
|
||
| // 로그인 | ||
|
|
@@ -205,6 +216,36 @@ public void deleteRefreshToken(RefreshTokenRequest request) { | |
|
|
||
|
|
||
|
|
||
| // 이메일 인증 코드 발송 | ||
| public void sendEmailVerification(EmailSendRequest request) { | ||
| String code = String.format("%06d", SECURE_RANDOM.nextInt(1_000_000)); | ||
| String key = EMAIL_VERIFICATION_PREFIX + request.getEmail(); | ||
| redisTemplate.opsForValue().set(key, code, VERIFICATION_TTL_MINUTES, TimeUnit.MINUTES); | ||
| emailService.sendVerificationCode(request.getEmail(), code); | ||
| } | ||
|
|
||
| // 이메일 인증 코드 확인 | ||
| public EmailVerificationResponse verifyEmail(EmailVerificationRequest request) { | ||
| String key = EMAIL_VERIFICATION_PREFIX + request.getEmail(); | ||
| String stored = redisTemplate.opsForValue().get(key); | ||
|
|
||
| if (stored == null) { | ||
| throw new ExpiredVerificationCodeException(); | ||
| } | ||
| if (!stored.equals(request.getCode())) { | ||
| throw new InvalidVerificationCodeException(); | ||
| } | ||
|
Comment on lines
+232
to
+237
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Add attempt limiting to prevent OTP brute force. Line 232-237 allows unlimited verification attempts for a 6-digit code during TTL. This is a security gap on an unauthenticated endpoint. 🤖 Prompt for AI Agents |
||
|
|
||
| redisTemplate.delete(key); | ||
|
|
||
| LocalDateTime verifiedAt = LocalDateTime.now(); | ||
| return EmailVerificationResponse.builder() | ||
| .verified(true) | ||
| .email(request.getEmail()) | ||
| .verifiedAt(verifiedAt) | ||
| .build(); | ||
| } | ||
|
|
||
| // 리프레시 토큰 저장 (있으면 업데이트 없으면 생성) | ||
| private void saveRefreshToken(User user, String refreshToken) { | ||
| LocalDateTime expiresAt = LocalDateTime.now().plusDays(7); | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| package chainsawman.gesture.service; | ||
|
|
||
| import org.springframework.beans.factory.annotation.Value; | ||
| import org.springframework.http.*; | ||
| import org.springframework.stereotype.Service; | ||
| import org.springframework.web.client.RestTemplate; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Map; | ||
|
|
||
| @Service | ||
| public class EmailService { | ||
|
|
||
| private static final String BREVO_API_URL = "https://api.brevo.com/v3/smtp/email"; | ||
|
|
||
| @Value("${brevo.api.key}") | ||
| private String apiKey; | ||
|
|
||
| @Value("${brevo.template.verification-id}") | ||
| private int templateId; | ||
|
|
||
| private final RestTemplate restTemplate = new RestTemplate(); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Configure HTTP timeouts for Brevo calls to avoid request-thread hangs. Line 22 and Line 36 currently use a default Proposed fix `@Service`
public class EmailService {
@@
- private final RestTemplate restTemplate = new RestTemplate();
+ private final RestTemplate restTemplate;
+
+ public EmailService(RestTemplateBuilder restTemplateBuilder) {
+ this.restTemplate = restTemplateBuilder
+ .setConnectTimeout(Duration.ofSeconds(3))
+ .setReadTimeout(Duration.ofSeconds(5))
+ .build();
+ }
@@
- restTemplate.postForEntity(BREVO_API_URL, new HttpEntity<>(body, headers), String.class);
+ restTemplate.postForEntity(BREVO_API_URL, new HttpEntity<>(body, headers), String.class);Also applies to: 36-36 🤖 Prompt for AI Agents |
||
|
|
||
| public void sendVerificationCode(String toEmail, String code) { | ||
| HttpHeaders headers = new HttpHeaders(); | ||
| headers.set("api-key", apiKey); | ||
| headers.set("accept", "application/json"); | ||
| headers.setContentType(MediaType.APPLICATION_JSON); | ||
|
|
||
| Map<String, Object> body = Map.of( | ||
| "to", List.of(Map.of("email", toEmail)), | ||
| "templateId", templateId, | ||
| "params", Map.of("code", code) | ||
| ); | ||
|
|
||
| restTemplate.postForEntity(BREVO_API_URL, new HttpEntity<>(body, headers), String.class); | ||
| } | ||
|
Comment on lines
+24
to
+37
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Wrap Brevo client failures into a domain exception. Line 36 can throw transport/4xx/5xx exceptions directly. Convert them to a consistent domain error so API clients get stable error contracts. Proposed fix+import org.springframework.web.client.RestClientException;
@@
public void sendVerificationCode(String toEmail, String code) {
@@
- restTemplate.postForEntity(BREVO_API_URL, new HttpEntity<>(body, headers), String.class);
+ try {
+ restTemplate.postForEntity(BREVO_API_URL, new HttpEntity<>(body, headers), String.class);
+ } catch (RestClientException e) {
+ throw new RuntimeException("이메일 전송에 실패했습니다."); // replace with project DomainException
+ }
}🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| spring.datasource.url=jdbc:h2:mem:testdb;MODE=MySQL | ||
| spring.datasource.driver-class-name=org.h2.Driver | ||
| spring.datasource.username=sa | ||
| spring.datasource.password= | ||
|
|
||
| spring.jpa.hibernate.ddl-auto=create-drop | ||
| spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.H2Dialect | ||
|
|
||
| spring.jwt.private-key=classpath:keys/private_key.pem | ||
| spring.jwt.public-key=classpath:keys/public_key.pem | ||
|
|
||
| aws.region=ap-northeast-2 | ||
| aws.s3.bucket=test-bucket | ||
| cloud.aws.stack.auto=false | ||
| aws.disable-ec2-metadata=true | ||
|
|
||
| spring.data.redis.host=localhost | ||
|
|
||
| brevo.api.key=test-dummy-key | ||
| brevo.template.verification-id=1 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid leaving verification codes in Redis when email delivery fails.
At Line 223-224, code is persisted before the external send. If send fails, a valid code remains without user delivery.
Proposed fix
public void sendEmailVerification(EmailSendRequest request) { String code = String.format("%06d", SECURE_RANDOM.nextInt(1_000_000)); String key = EMAIL_VERIFICATION_PREFIX + request.getEmail(); - redisTemplate.opsForValue().set(key, code, VERIFICATION_TTL_MINUTES, TimeUnit.MINUTES); - emailService.sendVerificationCode(request.getEmail(), code); + redisTemplate.opsForValue().set(key, code, VERIFICATION_TTL_MINUTES, TimeUnit.MINUTES); + try { + emailService.sendVerificationCode(request.getEmail(), code); + } catch (RuntimeException e) { + redisTemplate.delete(key); + throw e; + } }🤖 Prompt for AI Agents