Skip to content

Commit 66960ab

Browse files
authored
[Fix] 이메일 관련 로직 수정 (#243)
* fix: 이메일 전송 시, 사용자 이메일 존재 여부 체크하는 로직 추가 * refactor: EmailService의 verifyCode 반환 타입 수정 * test: AuthServiceTest에 emailVerification메서드 테스트 코드 작성 * test: email 전송 성공 테스트 코드 작성 * refactor: verifyCode()의 위치를 EmailService에서 AuthService로 변경 * test: AuthServiceTest에 verifyCode 테스트코드 작성 * refactor: 사용하지 않는 import문 제거 * refactor: 소나큐브 피드백을 반영하여 return문 제거
1 parent 3ab8513 commit 66960ab

7 files changed

Lines changed: 228 additions & 53 deletions

File tree

src/main/java/org/programmers/signalbuddyfinal/domain/auth/controller/AuthController.java

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,14 +60,15 @@ public ResponseEntity<ApiResponse<Object>> reissue(
6060

6161
@PostMapping("/auth-code")
6262
public ResponseEntity<ApiResponse<Object>> authCode(@Valid @RequestBody EmailRequest email) {
63-
emailService.sendEmail(email);
63+
authService.emailVerification(email);
6464
return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoData());
6565
}
6666

6767
@PostMapping("/verify-code")
6868
public ResponseEntity<ApiResponse<Object>> verifyCode(
6969
@Valid @RequestBody VerifyCodeRequest verifyCodeRequest) {
70-
return emailService.verifyCode(verifyCodeRequest);
70+
authService.verifyCode(verifyCodeRequest);
71+
return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoData());
7172
}
7273

7374
@PostMapping("/social-login")

src/main/java/org/programmers/signalbuddyfinal/domain/auth/service/AuthService.java

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
package org.programmers.signalbuddyfinal.domain.auth.service;
22

33
import java.time.Duration;
4+
import java.util.concurrent.TimeUnit;
45
import lombok.RequiredArgsConstructor;
56
import lombok.extern.slf4j.Slf4j;
7+
import org.programmers.signalbuddyfinal.domain.auth.dto.EmailRequest;
68
import org.programmers.signalbuddyfinal.domain.auth.dto.LoginRequest;
79
import org.programmers.signalbuddyfinal.domain.auth.dto.LoginResponse;
810
import org.programmers.signalbuddyfinal.domain.auth.dto.LogoutResponse;
911
import org.programmers.signalbuddyfinal.domain.auth.dto.NewTokenResponse;
1012
import org.programmers.signalbuddyfinal.domain.auth.dto.ReissueResponse;
1113
import org.programmers.signalbuddyfinal.domain.auth.dto.SocialLoginRequest;
14+
import org.programmers.signalbuddyfinal.domain.auth.dto.VerifyCodeRequest;
15+
import org.programmers.signalbuddyfinal.domain.auth.entity.Purpose;
16+
import org.programmers.signalbuddyfinal.domain.auth.exception.AuthErrorCode;
1217
import org.programmers.signalbuddyfinal.domain.member.dto.MemberResponse;
1318
import org.programmers.signalbuddyfinal.domain.member.entity.Member;
19+
import org.programmers.signalbuddyfinal.domain.member.entity.enums.MemberStatus;
1420
import org.programmers.signalbuddyfinal.domain.member.exception.MemberErrorCode;
1521
import org.programmers.signalbuddyfinal.domain.member.mapper.MemberMapper;
1622
import org.programmers.signalbuddyfinal.domain.member.repository.MemberRepository;
@@ -19,6 +25,8 @@
1925
import org.programmers.signalbuddyfinal.global.security.basic.CustomUserDetails;
2026
import org.programmers.signalbuddyfinal.global.security.jwt.JwtService;
2127
import org.programmers.signalbuddyfinal.global.security.jwt.JwtUtil;
28+
import org.springframework.data.redis.core.RedisTemplate;
29+
import org.springframework.data.redis.core.ValueOperations;
2230
import org.springframework.http.HttpHeaders;
2331
import org.springframework.http.ResponseCookie;
2432
import org.springframework.security.authentication.AuthenticationManager;
@@ -36,6 +44,9 @@ public class AuthService {
3644
private final JwtService jwtService;
3745
private final MemberRepository memberRepository;
3846
private final FcmService fcmService;
47+
private final EmailService emailService;
48+
private final RedisTemplate<String, String> redisTemplate;
49+
static final String PREFIX = "auth:email:";
3950

4051
public ReissueResponse reissue(String refreshToken, String accessToken) {
4152
NewTokenResponse newTokenResponse = jwtService.reissue(refreshToken, accessToken);
@@ -105,6 +116,37 @@ public LogoutResponse logout(
105116
return new LogoutResponse(headers);
106117
}
107118

119+
public void emailVerification(EmailRequest emailRequest) {
120+
Member member = memberRepository.findByEmail(emailRequest.getEmail()).orElse(null);
121+
if (member == null || member.getMemberStatus() == MemberStatus.WITHDRAWAL) {
122+
throw new BusinessException(MemberErrorCode.NOT_FOUND_MEMBER);
123+
}
124+
emailService.sendEmail(emailRequest.getEmail());
125+
}
126+
127+
public void verifyCode(VerifyCodeRequest verifyCodeRequest) {
128+
129+
ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
130+
Purpose purpose = verifyCodeRequest.getPurpose();
131+
String email = verifyCodeRequest.getEmail();
132+
String code = verifyCodeRequest.getCode();
133+
134+
String correctCode = valueOperations.get(PREFIX + email);
135+
136+
if (correctCode == null) {
137+
throw new BusinessException(AuthErrorCode.INVALID_AUTH_CODE);
138+
} else if (!correctCode.equals(code)) {
139+
throw new BusinessException(AuthErrorCode.NOT_MATCH_AUTH_CODE);
140+
} else {
141+
redisTemplate.delete(PREFIX + email);
142+
143+
// 인증된 사용자 저장
144+
String newPrefix = PREFIX + purpose.name().toLowerCase() + ":";
145+
valueOperations.set(newPrefix + email, "authenticated", 10,
146+
TimeUnit.MINUTES);
147+
}
148+
}
149+
108150
private Authentication createAuthentication(String email, String password) {
109151
return authenticationManager.authenticate(
110152
new UsernamePasswordAuthenticationToken(email, password));

src/main/java/org/programmers/signalbuddyfinal/domain/auth/service/EmailService.java

Lines changed: 7 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,12 @@
33
import jakarta.mail.MessagingException;
44
import jakarta.mail.internet.MimeMessage;
55
import java.security.SecureRandom;
6-
import java.util.Random;
76
import java.util.concurrent.TimeUnit;
87
import lombok.RequiredArgsConstructor;
98
import lombok.extern.slf4j.Slf4j;
10-
import org.programmers.signalbuddyfinal.domain.auth.dto.EmailRequest;
11-
import org.programmers.signalbuddyfinal.domain.auth.dto.VerifyCodeRequest;
12-
import org.programmers.signalbuddyfinal.domain.auth.entity.Purpose;
13-
import org.programmers.signalbuddyfinal.domain.auth.exception.AuthErrorCode;
14-
import org.programmers.signalbuddyfinal.global.exception.BusinessException;
15-
import org.programmers.signalbuddyfinal.global.response.ApiResponse;
169
import org.springframework.data.redis.core.RedisTemplate;
1710
import org.springframework.data.redis.core.ValueOperations;
18-
import org.springframework.http.ResponseEntity;
11+
import org.springframework.mail.MailParseException;
1912
import org.springframework.mail.javamail.JavaMailSender;
2013
import org.springframework.mail.javamail.MimeMessageHelper;
2114
import org.springframework.scheduling.annotation.Async;
@@ -35,46 +28,19 @@ public class EmailService {
3528
static final String PREFIX = "auth:email:";
3629

3730
@Async
38-
public void sendEmail(EmailRequest emailRequest) {
31+
public void sendEmail(String email) {
3932

4033
MimeMessage message = javaMailSender.createMimeMessage();
4134
String code = createCode();
42-
4335
try {
4436
MimeMessageHelper helper = new MimeMessageHelper(message, true);
45-
helper.setTo(emailRequest.getEmail());
37+
helper.setTo(email);
4638
helper.setSubject("[signalBuddy] 인증코드가 발송되었습니다.");
4739
helper.setText(setContent(code), true);
48-
} catch (MessagingException e) {
49-
throw new BusinessException(AuthErrorCode.SEND_EMAIL_FAILED);
50-
}
51-
52-
codeSave(emailRequest.getEmail(), code);
53-
54-
javaMailSender.send(message);
55-
}
56-
57-
public ResponseEntity<ApiResponse<Object>> verifyCode(VerifyCodeRequest verifyCodeRequest) {
58-
59-
ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
60-
Purpose purpose = verifyCodeRequest.getPurpose();
61-
String email = verifyCodeRequest.getEmail();
62-
String code = verifyCodeRequest.getCode();
63-
64-
String correctCode = valueOperations.get(PREFIX + email);
65-
66-
if (correctCode == null) {
67-
throw new BusinessException(AuthErrorCode.INVALID_AUTH_CODE);
68-
} else if (!correctCode.equals(code)) {
69-
throw new BusinessException(AuthErrorCode.NOT_MATCH_AUTH_CODE);
70-
} else {
71-
redisTemplate.delete(PREFIX + email);
72-
73-
// 인증된 사용자 저장
74-
String newPrefix = PREFIX + purpose.name().toLowerCase() + ":";
75-
valueOperations.set(newPrefix + email, "authenticated", 10,
76-
TimeUnit.MINUTES);
77-
return ResponseEntity.ok().body(ApiResponse.createSuccessWithNoData());
40+
javaMailSender.send(message);
41+
codeSave(email, code);
42+
} catch (MailParseException | MessagingException e) {
43+
log.error("메세지가 전송되지 않았습니다.");
7844
}
7945
}
8046

@@ -102,11 +68,6 @@ private String setContent(String code) {
10268

10369
private void codeSave(String email, String code) {
10470

105-
// 이미 요청한 메일에 대한 인증코드가 존재하는 경우, 삭제한다.
106-
if (Boolean.TRUE.equals(redisTemplate.hasKey(PREFIX + email))) {
107-
redisTemplate.delete(PREFIX + email);
108-
}
109-
11071
ValueOperations<String, String> valueOperations = redisTemplate.opsForValue();
11172
valueOperations.set(PREFIX + email, code, 3, TimeUnit.MINUTES);
11273
}

src/main/java/org/programmers/signalbuddyfinal/global/security/jwt/RefreshTokenRepository.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ public class RefreshTokenRepository {
1515

1616
public void save(Long memberId, String refreshToken) {
1717
redisTemplate.opsForValue()
18-
.set(PREFIX + String.valueOf(memberId), refreshToken, 7, TimeUnit.DAYS);
18+
.set(PREFIX + memberId, refreshToken, 7, TimeUnit.DAYS);
1919
}
2020

2121
public String findByMemberId(String memberId) {

src/test/java/org/programmers/signalbuddyfinal/domain/auth/controller/AuthControllerTest.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ void successReissueTokens() throws Exception {
185185
void sendAuthenticationCode() throws Exception {
186186
// given
187187
EmailRequest emailRequest = new EmailRequest(member.getEmail());
188-
doNothing().when(emailService).sendEmail(any(EmailRequest.class));
188+
doNothing().when(emailService).sendEmail(anyString());
189189

190190
//when, then
191191
mockMvc.perform(post("/api/auth/auth-code")
@@ -214,7 +214,7 @@ void verifyAuthenticationCode() throws Exception {
214214

215215
ApiResponse<Object> apiResponse = ApiResponse.createSuccessWithNoData();
216216
ResponseEntity<ApiResponse<Object>> responseEntity = ResponseEntity.ok().body(apiResponse);
217-
when(emailService.verifyCode(any(VerifyCodeRequest.class))).thenReturn(responseEntity);
217+
doNothing().when(authService).verifyCode(any(VerifyCodeRequest.class));
218218

219219
//when, then
220220
mockMvc.perform(post("/api/auth/verify-code")

src/test/java/org/programmers/signalbuddyfinal/domain/auth/service/AuthServiceTest.java

Lines changed: 123 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@
44
import static org.assertj.core.api.Assertions.assertThat;
55
import static org.assertj.core.api.Assertions.assertThatThrownBy;
66
import static org.mockito.ArgumentMatchers.any;
7+
import static org.mockito.ArgumentMatchers.anyLong;
78
import static org.mockito.ArgumentMatchers.anyString;
9+
import static org.mockito.BDDMockito.given;
810
import static org.mockito.Mockito.doNothing;
911
import static org.mockito.Mockito.times;
1012
import static org.mockito.Mockito.verify;
@@ -19,12 +21,15 @@
1921
import org.mockito.InjectMocks;
2022
import org.mockito.Mock;
2123
import org.mockito.junit.jupiter.MockitoExtension;
24+
import org.programmers.signalbuddyfinal.domain.auth.dto.EmailRequest;
2225
import org.programmers.signalbuddyfinal.domain.auth.dto.LoginRequest;
2326
import org.programmers.signalbuddyfinal.domain.auth.dto.LoginResponse;
2427
import org.programmers.signalbuddyfinal.domain.auth.dto.LogoutResponse;
2528
import org.programmers.signalbuddyfinal.domain.auth.dto.NewTokenResponse;
2629
import org.programmers.signalbuddyfinal.domain.auth.dto.ReissueResponse;
2730
import org.programmers.signalbuddyfinal.domain.auth.dto.SocialLoginRequest;
31+
import org.programmers.signalbuddyfinal.domain.auth.dto.VerifyCodeRequest;
32+
import org.programmers.signalbuddyfinal.domain.auth.entity.Purpose;
2833
import org.programmers.signalbuddyfinal.domain.auth.exception.AuthErrorCode;
2934
import org.programmers.signalbuddyfinal.domain.member.entity.Member;
3035
import org.programmers.signalbuddyfinal.domain.member.entity.enums.MemberRole;
@@ -38,6 +43,8 @@
3843
import org.programmers.signalbuddyfinal.global.security.basic.CustomUserDetails;
3944
import org.programmers.signalbuddyfinal.global.security.jwt.JwtService;
4045
import org.programmers.signalbuddyfinal.global.security.jwt.JwtUtil;
46+
import org.springframework.data.redis.core.RedisTemplate;
47+
import org.springframework.data.redis.core.ValueOperations;
4148
import org.springframework.security.authentication.AuthenticationManager;
4249
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
4350
import org.springframework.security.core.Authentication;
@@ -63,6 +70,15 @@ class AuthServiceTest {
6370
@Mock
6471
private JwtUtil jwtUtil;
6572

73+
@Mock
74+
EmailService emailService;
75+
76+
@Mock
77+
RedisTemplate<String, String> redisTemplate;
78+
79+
@Mock
80+
ValueOperations<String, String> valueOperations;
81+
6682
private Member member;
6783
private String deviceTokenCookie = "deviceToken";
6884
private CustomUserDetails customUserDetails;
@@ -106,7 +122,8 @@ void givenWithdrawalMember_whenBasicLogin_thenReturnWithdrawnMemberMessage() {
106122
// given
107123
Member withdrawalMember = createMember(MemberStatus.WITHDRAWAL);
108124

109-
LoginRequest loginRequest = new LoginRequest(withdrawalMember.getEmail(), withdrawalMember.getPassword());
125+
LoginRequest loginRequest = new LoginRequest(withdrawalMember.getEmail(),
126+
withdrawalMember.getPassword());
110127
when(authenticationManager.authenticate(any())).thenThrow(
111128
new BusinessException(MemberErrorCode.WITHDRAWN_MEMBER));
112129

@@ -310,7 +327,111 @@ void givenValidToken_whenLogout_thenSuccess() {
310327
assertThat(afterLogoutSetCookieHeader).contains("Max-Age=0");
311328
}
312329

313-
private Member createMember(MemberStatus status){
330+
@Nested
331+
@DisplayName("이메일 검증")
332+
class whenVerifyEmail {
333+
334+
@Test
335+
@DisplayName("이메일이 존재하는 경우, EmailService의 sendEmail을 호출한다.")
336+
void givenExistedEmail_whenEmailVerification_thenCallEmailService() {
337+
//given
338+
when(memberRepository.findByEmail(member.getEmail())).thenReturn(Optional.of(member));
339+
doNothing().when(emailService).sendEmail(member.getEmail());
340+
341+
//when
342+
authService.emailVerification(new EmailRequest(member.getEmail()));
343+
344+
//then
345+
verify(emailService, times(1)).sendEmail(member.getEmail());
346+
}
347+
348+
@Test
349+
@DisplayName("이메일이 존재하지 않는 경우, NotFoundMember를 던진다.")
350+
void givenNotExistedMember_whenEmailVerification_thenThrowsNotFoundError() {
351+
//given
352+
when(memberRepository.findByEmail(member.getEmail())).thenReturn(Optional.empty());
353+
354+
//when
355+
assertThatThrownBy(
356+
() -> authService.emailVerification(new EmailRequest(member.getEmail())))
357+
.isInstanceOf(BusinessException.class)
358+
.hasMessageContaining(MemberErrorCode.NOT_FOUND_MEMBER.getMessage());
359+
360+
verify(emailService, times(0)).sendEmail(member.getEmail());
361+
}
362+
363+
@Test
364+
@DisplayName("탈퇴한 회원인 경우, NotFoundMember를 던진다.")
365+
void givenWithdrawalMember_whenEmailVerification_thenThrowsNotFoundError() {
366+
//given
367+
Member withdrawalMember = createMember(MemberStatus.WITHDRAWAL);
368+
when(memberRepository.findByEmail(withdrawalMember.getEmail())).thenReturn(
369+
Optional.of(withdrawalMember));
370+
371+
//when
372+
assertThatThrownBy(
373+
() -> authService.emailVerification(new EmailRequest(withdrawalMember.getEmail())))
374+
.isInstanceOf(BusinessException.class)
375+
.hasMessageContaining(MemberErrorCode.NOT_FOUND_MEMBER.getMessage());
376+
377+
verify(emailService, times(0)).sendEmail(member.getEmail());
378+
}
379+
}
380+
381+
@Nested
382+
@DisplayName("인증 코드 검증")
383+
class whenVerifyCode {
384+
385+
VerifyCodeRequest verifyCodeRequest = new VerifyCodeRequest(Purpose.NEW_PASSWORD,
386+
"test@test.com", "123456");
387+
388+
@BeforeEach
389+
void verifyCodeSetup() {
390+
given(redisTemplate.opsForValue()).willReturn(valueOperations);
391+
}
392+
393+
@Test
394+
@DisplayName("인증에 성공한다.")
395+
void givenValidCode_whenVerifyCode_thenReturnVoid() {
396+
// given
397+
when(valueOperations.get(any())).thenReturn(verifyCodeRequest.getCode());
398+
when(redisTemplate.delete(anyString())).thenReturn(true);
399+
doNothing().when(valueOperations).set(any(), any(), any(Long.class), any());
400+
401+
// when
402+
authService.verifyCode(verifyCodeRequest);
403+
404+
// then
405+
verify(redisTemplate, times(1)).delete(anyString());
406+
verify(valueOperations, times(1)).set(anyString(), anyString(), anyLong(), any());
407+
}
408+
409+
@Test
410+
@DisplayName("인증 유효 시간이 끝나서 INVALID_AUTH_CODE 에러를 반환한다.")
411+
void givenAuthExpirationTimeIsEnd_whenVerifyCode_thenThrowsInvalidAuthCodeError() {
412+
// given
413+
when(valueOperations.get(any())).thenReturn(null);
414+
415+
// when & then
416+
assertThatThrownBy(() -> authService.verifyCode(verifyCodeRequest))
417+
.isInstanceOf(BusinessException.class)
418+
.hasMessageContaining(AuthErrorCode.INVALID_AUTH_CODE.getMessage());
419+
}
420+
421+
@Test
422+
@DisplayName("인증코드가 일치하지 않아 NOT_MATCH_AUTH_CODE 에러를 반환한다.")
423+
void givenNotMatchedAuthCode_whenVerifyCode_thenThrowsNotMatchAuthCodeError() {
424+
// given
425+
when(valueOperations.get(any())).thenReturn("wrong");
426+
427+
// when & then
428+
assertThatThrownBy(() -> authService.verifyCode(verifyCodeRequest))
429+
.isInstanceOf(BusinessException.class)
430+
.hasMessageContaining(AuthErrorCode.NOT_MATCH_AUTH_CODE.getMessage());
431+
}
432+
}
433+
434+
private Member createMember(MemberStatus status) {
314435
return Member.builder()
315436
.email("test@test.com")
316437
.nickname("테스트")

0 commit comments

Comments
 (0)