Skip to content

Commit 4c1c509

Browse files
authored
Merge pull request #98 from Leets-Official/fix/email-verification
fix: email verification
2 parents be33371 + dc4a3c2 commit 4c1c509

4 files changed

Lines changed: 114 additions & 6 deletions

File tree

.env.example

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,9 @@ SPRING_DATASOURCE_PASSWORD=
66
# 참고 - 운영 배포 사고를 막기 위해 기본값을 의도적으로 두지 않았다). 충분히 긴 랜덤 문자열로 채울 것.
77
JWT_SECRET=
88
CLIENT_ALLOWED_ORIGINS=http://localhost:[*],http://127.0.0.1:[*]
9+
10+
# 이메일 인증코드 발송용 Gmail SMTP 계정. 비밀번호는 Google 계정 로그인 비밀번호가 아니라
11+
# 2단계 인증 후 발급받는 "앱 비밀번호"를 써야 한다(https://myaccount.google.com/apppasswords).
12+
# 비어있으면 Gmail 인증에 실패해 /email/send-code가 500(EMAIL_SEND_FAILED)을 반환한다.
13+
MAIL_USERNAME=
14+
MAIL_APP_PASSWORD=

.github/workflows/deploy.yml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,8 @@ jobs:
9494
SPRING_DATASOURCE_PASSWORD: ${{ secrets.SPRING_DATASOURCE_PASSWORD }}
9595
JWT_SECRET: ${{ secrets.JWT_SECRET }}
9696
CLIENT_ALLOWED_ORIGINS: ${{ secrets.CLIENT_ALLOWED_ORIGINS }}
97+
MAIL_USERNAME: ${{ secrets.MAIL_USERNAME }}
98+
MAIL_APP_PASSWORD: ${{ secrets.MAIL_APP_PASSWORD }}
9799
S3_PRIVATE_BUCKET: ${{ vars.S3_PRIVATE_BUCKET }}
98100
S3_PUBLIC_BUCKET: ${{ vars.S3_PUBLIC_BUCKET }}
99101
S3_PUBLIC_BASE_URL: ${{ vars.S3_PUBLIC_BASE_URL }}
@@ -103,7 +105,7 @@ jobs:
103105
echo "::error::JWT_SECRET is not configured"
104106
exit 1
105107
}
106-
for name in S3_PRIVATE_BUCKET S3_PUBLIC_BUCKET S3_PUBLIC_BASE_URL AWS_REGION; do
108+
for name in S3_PRIVATE_BUCKET S3_PUBLIC_BUCKET S3_PUBLIC_BASE_URL AWS_REGION MAIL_USERNAME MAIL_APP_PASSWORD; do
107109
test -n "${!name}" || {
108110
echo "::error::$name is not configured"
109111
exit 1
@@ -116,6 +118,8 @@ jobs:
116118
printf 'SPRING_DATASOURCE_PASSWORD=%s\n' "$SPRING_DATASOURCE_PASSWORD"
117119
printf 'JWT_SECRET=%s\n' "$JWT_SECRET"
118120
printf 'CLIENT_ALLOWED_ORIGINS=%s\n' "$CLIENT_ALLOWED_ORIGINS"
121+
printf 'MAIL_USERNAME=%s\n' "$MAIL_USERNAME"
122+
printf 'MAIL_APP_PASSWORD=%s\n' "$MAIL_APP_PASSWORD"
119123
printf 'S3_PRIVATE_BUCKET=%s\n' "$S3_PRIVATE_BUCKET"
120124
printf 'S3_PUBLIC_BUCKET=%s\n' "$S3_PUBLIC_BUCKET"
121125
printf 'S3_PUBLIC_BASE_URL=%s\n' "$S3_PUBLIC_BASE_URL"

src/main/java/com/leets/tdd/auth/service/EmailVerificationService.java

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.leets.tdd.auth.exception.AuthErrorCode;
88
import com.leets.tdd.auth.exception.AuthException;
99
import com.leets.tdd.auth.repository.EmailVerificationRepository;
10+
import com.leets.tdd.user.domain.User;
1011
import com.leets.tdd.user.repository.UserRepository;
1112
import lombok.RequiredArgsConstructor;
1213
import org.springframework.security.crypto.password.PasswordEncoder;
@@ -143,9 +144,27 @@ public boolean consumePasswordResetVerification(String email) {
143144
email, EmailPurpose.RESET_PASSWORD, PASSWORD_RESET_COMPLETION_WINDOW);
144145
}
145146

147+
/**
148+
* 재가입(SIGNUP) 시 기존 회원(email로 찾은) 상태별 발송 가능 여부.
149+
* ACTIVE/SUSPENDED: 이미 쓰고 있는 계정 -> 이미 가입된 이메일
150+
* BANNED: 영구 제한 -> 발송 불가
151+
* DELETED: 탈퇴했던 계정(soft delete라 noShowApprovedCount/suspendedUntil/mannerTemperature는
152+
* 유지됨). 정지기간이 아직 안 지났으면(탈퇴로 정지 우회 방지) 발송 불가,
153+
* 지났거나 정지 이력이 없으면 재사용(reactivate) 대상으로 통과시켜 코드를 발송한다.
154+
*/
146155
private void validateNotAlreadyRegistered(String email) {
147-
if (userRepository.existsByEmail(email)) {
148-
throw new AuthException(AuthErrorCode.ALREADY_REGISTERED_EMAIL);
156+
userRepository.findByEmail(email).ifPresent(this::validateExistingUserForSignup);
157+
}
158+
159+
private void validateExistingUserForSignup(User user) {
160+
switch (user.getStatus()) {
161+
case ACTIVE, SUSPENDED -> throw new AuthException(AuthErrorCode.ALREADY_REGISTERED_EMAIL);
162+
case BANNED -> throw new AuthException(AuthErrorCode.ACCOUNT_BANNED);
163+
case DELETED -> {
164+
if (user.isWithinSuspensionPeriod()) {
165+
throw new AuthException(AuthErrorCode.ACCOUNT_BANNED);
166+
}
167+
}
149168
}
150169
}
151170

src/test/java/com/leets/tdd/auth/service/EmailVerificationServiceTest.java

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import com.leets.tdd.auth.exception.AuthErrorCode;
88
import com.leets.tdd.auth.exception.AuthException;
99
import com.leets.tdd.auth.repository.EmailVerificationRepository;
10+
import com.leets.tdd.user.domain.User;
1011
import com.leets.tdd.user.repository.UserRepository;
1112
import org.junit.jupiter.api.DisplayName;
1213
import org.junit.jupiter.api.Test;
@@ -48,11 +49,31 @@ class EmailVerificationServiceTest {
4849
@InjectMocks
4950
private EmailVerificationService emailVerificationService;
5051

52+
private User existingUser() {
53+
return new User("abcd@gachon.ac.kr", "가나디", "encoded-pw",
54+
"old-refresh-hash", LocalDateTime.now().plusDays(30));
55+
}
56+
57+
@Test
58+
@DisplayName("SIGNUP 목적으로 이미 가입된(ACTIVE) 이메일이면 예외가 발생한다")
59+
void alreadyRegisteredEmail_active() {
60+
EmailVerificationRequest request = new EmailVerificationRequest("abcd@gachon.ac.kr", EmailPurpose.SIGNUP);
61+
when(userRepository.findByEmail("abcd@gachon.ac.kr")).thenReturn(Optional.of(existingUser()));
62+
63+
assertThatThrownBy(() -> emailVerificationService.sendVerificationCode(request))
64+
.isInstanceOf(AuthException.class)
65+
.hasMessage(AuthErrorCode.ALREADY_REGISTERED_EMAIL.getMessage());
66+
67+
verifyNoInteractions(mailService);
68+
}
69+
5170
@Test
52-
@DisplayName("SIGNUP 목적으로 이미 가입된 이메일이면 예외가 발생한다")
53-
void alreadyRegisteredEmail() {
71+
@DisplayName("SIGNUP 목적으로 이미 가입된(SUSPENDED) 이메일이면 예외가 발생한다")
72+
void alreadyRegisteredEmail_suspended() {
5473
EmailVerificationRequest request = new EmailVerificationRequest("abcd@gachon.ac.kr", EmailPurpose.SIGNUP);
55-
when(userRepository.existsByEmail("abcd@gachon.ac.kr")).thenReturn(true);
74+
User suspended = existingUser();
75+
suspended.suspend(LocalDateTime.now().plusDays(3));
76+
when(userRepository.findByEmail("abcd@gachon.ac.kr")).thenReturn(Optional.of(suspended));
5677

5778
assertThatThrownBy(() -> emailVerificationService.sendVerificationCode(request))
5879
.isInstanceOf(AuthException.class)
@@ -61,6 +82,64 @@ void alreadyRegisteredEmail() {
6182
verifyNoInteractions(mailService);
6283
}
6384

85+
@Test
86+
@DisplayName("SIGNUP 목적으로 BANNED 계정이면 이용이 제한된 계정 예외가 발생한다")
87+
void signup_bannedAccount_throwsAccountBanned() {
88+
EmailVerificationRequest request = new EmailVerificationRequest("abcd@gachon.ac.kr", EmailPurpose.SIGNUP);
89+
User banned = existingUser();
90+
banned.ban();
91+
when(userRepository.findByEmail("abcd@gachon.ac.kr")).thenReturn(Optional.of(banned));
92+
93+
assertThatThrownBy(() -> emailVerificationService.sendVerificationCode(request))
94+
.isInstanceOf(AuthException.class)
95+
.hasMessage(AuthErrorCode.ACCOUNT_BANNED.getMessage());
96+
97+
verifyNoInteractions(mailService);
98+
}
99+
100+
@Test
101+
@DisplayName("SIGNUP 목적으로 DELETED 계정이고 정지기간이 아직 안 지났으면 이용이 제한된 계정 예외가 발생한다")
102+
void signup_deletedWithinSuspension_throwsAccountBanned() {
103+
EmailVerificationRequest request = new EmailVerificationRequest("abcd@gachon.ac.kr", EmailPurpose.SIGNUP);
104+
User deleted = existingUser();
105+
deleted.suspend(LocalDateTime.now().plusDays(3));
106+
deleted.softDelete();
107+
when(userRepository.findByEmail("abcd@gachon.ac.kr")).thenReturn(Optional.of(deleted));
108+
109+
assertThatThrownBy(() -> emailVerificationService.sendVerificationCode(request))
110+
.isInstanceOf(AuthException.class)
111+
.hasMessage(AuthErrorCode.ACCOUNT_BANNED.getMessage());
112+
113+
verifyNoInteractions(mailService);
114+
}
115+
116+
@Test
117+
@DisplayName("SIGNUP 목적으로 DELETED 계정이고 정지기간이 지났으면 재가입으로 간주해 코드를 발송한다")
118+
void signup_deletedAfterSuspension_sendsCode() {
119+
EmailVerificationRequest request = new EmailVerificationRequest("abcd@gachon.ac.kr", EmailPurpose.SIGNUP);
120+
User deleted = existingUser();
121+
deleted.suspend(LocalDateTime.now().minusDays(1));
122+
deleted.softDelete();
123+
when(userRepository.findByEmail("abcd@gachon.ac.kr")).thenReturn(Optional.of(deleted));
124+
125+
assertThatCode(() -> emailVerificationService.sendVerificationCode(request)).doesNotThrowAnyException();
126+
127+
verify(mailService).sendVerificationCode(eq("abcd@gachon.ac.kr"), anyString());
128+
}
129+
130+
@Test
131+
@DisplayName("SIGNUP 목적으로 DELETED 계정이고 정지 이력이 없으면 재가입으로 간주해 코드를 발송한다")
132+
void signup_deletedWithoutSuspensionHistory_sendsCode() {
133+
EmailVerificationRequest request = new EmailVerificationRequest("abcd@gachon.ac.kr", EmailPurpose.SIGNUP);
134+
User deleted = existingUser();
135+
deleted.softDelete();
136+
when(userRepository.findByEmail("abcd@gachon.ac.kr")).thenReturn(Optional.of(deleted));
137+
138+
assertThatCode(() -> emailVerificationService.sendVerificationCode(request)).doesNotThrowAnyException();
139+
140+
verify(mailService).sendVerificationCode(eq("abcd@gachon.ac.kr"), anyString());
141+
}
142+
64143
@Test
65144
@DisplayName("5분 내 요청 횟수(3회)를 초과하면 예외가 발생한다")
66145
void exceedRequestLimit() {

0 commit comments

Comments
 (0)