Skip to content

Commit ac51829

Browse files
authored
fix: 학교 이메일 중복 인증을 차단한다 (#808)
* fix: 인증 완료 학교 이메일 저장 구조를 추가 * fix: 학교 이메일 중복 인증을 차단 * test: 학교 이메일 중복 인증 차단을 검증 * fix: 소비된 조회수 Redis 키를 무시한다 * fix: 학교 이메일 요청 길이 검증을 추가한다
1 parent adcfb23 commit ac51829

11 files changed

Lines changed: 294 additions & 11 deletions

File tree

‎src/main/java/com/example/solidconnection/common/exception/ErrorCode.java‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ public enum ErrorCode {
8383

8484
// school email verification
8585
SCHOOL_EMAIL_ALREADY_VERIFIED(HttpStatus.BAD_REQUEST.value(), "이미 학교 이메일 인증이 완료되었습니다."),
86+
SCHOOL_EMAIL_ALREADY_USED(HttpStatus.CONFLICT.value(), "이미 인증에 사용된 학교 이메일입니다."),
8687
SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 학교 이메일 도메인입니다."),
8788
SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "학교 이메일 인증 요청을 찾을 수 없습니다. 인증 코드 발송을 다시 요청해주세요."),
8889
SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT(HttpStatus.BAD_REQUEST.value(), "인증 코드가 일치하지 않습니다."),

‎src/main/java/com/example/solidconnection/community/post/service/UpdateViewCountService.java‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,9 @@ public class UpdateViewCountService {
2222
public void updateViewCount(String key) {
2323
Long postId = postRedisManager.getPostIdFromPostViewCountRedisKey(key);
2424
Long viewCount = postRedisManager.getAndDeleteViewCount(key);
25+
if (viewCount == null) {
26+
return;
27+
}
2528
postRepository.increaseViewCount(postId, viewCount);
2629
}
2730
}

‎src/main/java/com/example/solidconnection/siteuser/domain/SiteUser.java‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,10 @@
3030
@UniqueConstraint(
3131
name = "uk_site_user_nickname",
3232
columnNames = {"nickname"}
33+
),
34+
@UniqueConstraint(
35+
name = "uk_site_user_verified_school_email",
36+
columnNames = {"verified_school_email"}
3337
)
3438
})
3539
public class SiteUser extends BaseEntity {
@@ -54,6 +58,9 @@ public class SiteUser extends BaseEntity {
5458
@Column(name = "home_university_id", nullable = true)
5559
private Long homeUniversityId;
5660

61+
@Column(name = "verified_school_email", nullable = true, length = 100)
62+
private String verifiedSchoolEmail;
63+
5764
@Setter
5865
@Column(name = "profile_image_url", length = 500)
5966
private String profileImageUrl;
@@ -159,8 +166,9 @@ public void updateUserStatus(UserStatus status) {
159166
this.userStatus = status;
160167
}
161168

162-
public void verifySchool(Long homeUniversityId) {
169+
public void verifySchool(Long homeUniversityId, String verifiedSchoolEmail) {
163170
this.homeUniversityId = homeUniversityId;
171+
this.verifiedSchoolEmail = verifiedSchoolEmail;
164172
}
165173

166174
public void becomeMentor() {

‎src/main/java/com/example/solidconnection/siteuser/dto/SchoolEmailRequest.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,12 @@
22

33
import jakarta.validation.constraints.Email;
44
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Size;
56

67
public record SchoolEmailRequest(
78
@NotBlank(message = "학교 이메일은 필수입니다")
89
@Email(message = "올바른 이메일 형식이 아닙니다")
10+
@Size(max = 100, message = "학교 이메일은 100자 이하여야 합니다")
911
String schoolEmail
1012
) {
1113

‎src/main/java/com/example/solidconnection/siteuser/repository/SiteUserRepository.java‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ public interface SiteUserRepository extends JpaRepository<SiteUser, Long>, SiteU
2020

2121
boolean existsByNickname(String nickname);
2222

23+
boolean existsByVerifiedSchoolEmail(String verifiedSchoolEmail);
24+
2325
@Query("SELECT u FROM SiteUser u WHERE u.quitedAt <= :cutoffDate")
2426
List<SiteUser> findUsersToBeRemoved(@Param("cutoffDate") LocalDate cutoffDate);
2527

‎src/main/java/com/example/solidconnection/siteuser/service/SchoolEmailService.java‎

Lines changed: 66 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.example.solidconnection.siteuser.service;
22

33
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_VERIFIED;
4+
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_USED;
45
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT;
56
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND;
67
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED;
@@ -17,9 +18,11 @@
1718
import com.example.solidconnection.university.repository.HomeUniversityRepository;
1819
import com.fasterxml.jackson.core.JsonProcessingException;
1920
import com.fasterxml.jackson.databind.ObjectMapper;
21+
import java.util.Locale;
2022
import java.util.concurrent.ThreadLocalRandom;
2123
import java.util.concurrent.TimeUnit;
2224
import lombok.RequiredArgsConstructor;
25+
import org.springframework.dao.DataIntegrityViolationException;
2326
import org.springframework.data.redis.core.RedisTemplate;
2427
import org.springframework.stereotype.Service;
2528
import org.springframework.transaction.annotation.Transactional;
@@ -46,15 +49,17 @@ public void requestSchoolEmailVerification(long siteUserId, String schoolEmail)
4649
throw new CustomException(SCHOOL_EMAIL_ALREADY_VERIFIED);
4750
}
4851

49-
String domain = extractEmailDomain(schoolEmail);
52+
String normalizedSchoolEmail = normalizeSchoolEmail(schoolEmail);
53+
String domain = extractEmailDomain(normalizedSchoolEmail);
5054
HomeUniversity homeUniversity = homeUniversityRepository.findByEmailDomain(domain)
5155
.orElseThrow(() -> new CustomException(SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED));
56+
validateVerifiedSchoolEmailNotDuplicated(normalizedSchoolEmail);
5257

5358
String code = generateVerificationCode();
54-
saveVerificationInfo(siteUserId, new SchoolVerificationInfo(schoolEmail, homeUniversity.getId(), code));
59+
saveVerificationInfo(siteUserId, new SchoolVerificationInfo(normalizedSchoolEmail, homeUniversity.getId(), code));
5560

5661
try {
57-
mailService.sendVerificationEmail(schoolEmail, code);
62+
mailService.sendVerificationEmail(normalizedSchoolEmail, code);
5863
} catch (Exception e) {
5964
redisTemplate.delete(KEY_PREFIX + siteUserId);
6065
throw e;
@@ -72,10 +77,21 @@ public void confirmSchoolEmail(long siteUserId, String code) {
7277
throw new CustomException(SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT);
7378
}
7479

75-
siteUser.verifySchool(info.getHomeUniversityId());
80+
String verifiedSchoolEmail = normalizeSchoolEmail(info.getSchoolEmail());
81+
validateVerifiedSchoolEmailNotDuplicated(verifiedSchoolEmail);
82+
siteUser.verifySchool(info.getHomeUniversityId(), verifiedSchoolEmail);
83+
flushVerifiedSchoolEmail();
7684
redisTemplate.delete(KEY_PREFIX + siteUserId);
7785
}
7886

87+
private void flushVerifiedSchoolEmail() {
88+
try {
89+
siteUserRepository.flush();
90+
} catch (DataIntegrityViolationException e) {
91+
throw new CustomException(SCHOOL_EMAIL_ALREADY_USED);
92+
}
93+
}
94+
7995
private void saveVerificationInfo(long siteUserId, SchoolVerificationInfo info) {
8096
try {
8197
redisTemplate.opsForValue().set(
@@ -95,15 +111,57 @@ private SchoolVerificationInfo getVerificationInfo(long siteUserId) {
95111
throw new CustomException(SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND);
96112
}
97113
try {
98-
return objectMapper.readValue(jsonInfo, SchoolVerificationInfo.class);
114+
SchoolVerificationInfo info = objectMapper.readValue(jsonInfo, SchoolVerificationInfo.class);
115+
validateVerificationInfo(siteUserId, info);
116+
return info;
99117
} catch (JsonProcessingException e) {
100-
redisTemplate.delete(KEY_PREFIX + siteUserId);
101-
throw new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED);
118+
throw corruptedVerificationInfoException(siteUserId);
119+
}
120+
}
121+
122+
private void validateVerificationInfo(long siteUserId, SchoolVerificationInfo info) {
123+
if (info == null
124+
|| isBlank(info.getSchoolEmail())
125+
|| !hasEmailDomain(info.getSchoolEmail())
126+
|| info.getHomeUniversityId() == null
127+
|| isBlank(info.getCode())
128+
|| !homeUniversityRepository.existsById(info.getHomeUniversityId())) {
129+
throw corruptedVerificationInfoException(siteUserId);
130+
}
131+
}
132+
133+
private boolean isBlank(String value) {
134+
return value == null || value.isBlank();
135+
}
136+
137+
private CustomException corruptedVerificationInfoException(long siteUserId) {
138+
redisTemplate.delete(KEY_PREFIX + siteUserId);
139+
return new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED);
140+
}
141+
142+
private void validateVerifiedSchoolEmailNotDuplicated(String verifiedSchoolEmail) {
143+
if (siteUserRepository.existsByVerifiedSchoolEmail(verifiedSchoolEmail)) {
144+
throw new CustomException(SCHOOL_EMAIL_ALREADY_USED);
102145
}
103146
}
104147

148+
private String normalizeSchoolEmail(String schoolEmail) {
149+
return schoolEmail.trim().toLowerCase(Locale.ROOT);
150+
}
151+
105152
private String extractEmailDomain(String email) {
106-
return email.substring(email.indexOf('@') + 1).toLowerCase();
153+
if (!hasEmailDomain(email)) {
154+
throw new CustomException(SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED);
155+
}
156+
return email.substring(email.indexOf('@') + 1);
157+
}
158+
159+
private boolean hasEmailDomain(String email) {
160+
if (email == null) {
161+
return false;
162+
}
163+
int atIndex = email.indexOf('@');
164+
return atIndex > 0 && atIndex < email.length() - 1;
107165
}
108166

109167
private String generateVerificationCode() {
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
ALTER TABLE site_user
2+
ADD COLUMN verified_school_email VARCHAR(100) NULL,
3+
ADD CONSTRAINT uk_site_user_verified_school_email UNIQUE (verified_school_email);
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
package com.example.solidconnection.community.post.service;
2+
3+
import static org.mockito.BDDMockito.given;
4+
import static org.mockito.BDDMockito.then;
5+
6+
import com.example.solidconnection.community.post.repository.PostRepository;
7+
import org.junit.jupiter.api.DisplayName;
8+
import org.junit.jupiter.api.Test;
9+
import org.junit.jupiter.api.extension.ExtendWith;
10+
import org.mockito.InjectMocks;
11+
import org.mockito.Mock;
12+
import org.mockito.junit.jupiter.MockitoExtension;
13+
14+
@DisplayName("조회수 업데이트 서비스 테스트")
15+
@ExtendWith(MockitoExtension.class)
16+
class UpdateViewCountServiceTest {
17+
18+
@InjectMocks
19+
private UpdateViewCountService updateViewCountService;
20+
21+
@Mock
22+
private PostRepository postRepository;
23+
24+
@Mock
25+
private PostRedisManager postRedisManager;
26+
27+
@Test
28+
void Redis_조회수_키가_이미_소비되었으면_DB_업데이트를_하지_않는다() {
29+
// given
30+
String key = "post:view:1";
31+
given(postRedisManager.getPostIdFromPostViewCountRedisKey(key)).willReturn(1L);
32+
given(postRedisManager.getAndDeleteViewCount(key)).willReturn(null);
33+
34+
// when
35+
updateViewCountService.updateViewCount(key);
36+
37+
// then
38+
then(postRepository).shouldHaveNoInteractions();
39+
}
40+
41+
@Test
42+
void Redis_조회수가_있으면_DB_조회수를_증가시킨다() {
43+
// given
44+
String key = "post:view:1";
45+
given(postRedisManager.getPostIdFromPostViewCountRedisKey(key)).willReturn(1L);
46+
given(postRedisManager.getAndDeleteViewCount(key)).willReturn(2L);
47+
48+
// when
49+
updateViewCountService.updateViewCount(key);
50+
51+
// then
52+
then(postRepository).should().increaseViewCount(1L, 2L);
53+
}
54+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package com.example.solidconnection.siteuser.dto;
2+
3+
import static org.assertj.core.api.Assertions.assertThat;
4+
5+
import jakarta.validation.ConstraintViolation;
6+
import jakarta.validation.Validation;
7+
import jakarta.validation.Validator;
8+
import jakarta.validation.ValidatorFactory;
9+
import java.util.Set;
10+
import org.junit.jupiter.api.BeforeEach;
11+
import org.junit.jupiter.api.DisplayName;
12+
import org.junit.jupiter.api.Nested;
13+
import org.junit.jupiter.api.Test;
14+
15+
@DisplayName("학교 이메일 인증 요청 유효성 검사 테스트")
16+
class SchoolEmailRequestTest {
17+
18+
private static final String MESSAGE = "message";
19+
private static final String SCHOOL_EMAIL_MAX_LENGTH_MESSAGE = "학교 이메일은 100자 이하여야 합니다";
20+
21+
private Validator validator;
22+
23+
@BeforeEach
24+
void setUp() {
25+
ValidatorFactory factory = Validation.buildDefaultValidatorFactory();
26+
validator = factory.getValidator();
27+
}
28+
29+
@Nested
30+
class 학교_이메일_길이_검증 {
31+
32+
@Test
33+
void 학교_이메일이_100자이면_검증을_통과한다() {
34+
// given
35+
SchoolEmailRequest request = new SchoolEmailRequest(emailWithLength(100));
36+
37+
// when
38+
Set<ConstraintViolation<SchoolEmailRequest>> violations = validator.validate(request);
39+
40+
// then
41+
assertThat(violations).isEmpty();
42+
}
43+
44+
@Test
45+
void 학교_이메일이_100자를_초과하면_검증에_실패한다() {
46+
// given
47+
SchoolEmailRequest request = new SchoolEmailRequest(emailWithLength(101));
48+
49+
// when
50+
Set<ConstraintViolation<SchoolEmailRequest>> violations = validator.validate(request);
51+
52+
// then
53+
assertThat(violations)
54+
.extracting(MESSAGE)
55+
.contains(SCHOOL_EMAIL_MAX_LENGTH_MESSAGE);
56+
}
57+
}
58+
59+
private String emailWithLength(int length) {
60+
String prefix = "a@";
61+
String domainPrefix = "b".repeat(63) + ".";
62+
String suffix = ".edu";
63+
String domain = domainPrefix + "c".repeat(length - prefix.length() - domainPrefix.length() - suffix.length());
64+
return prefix + domain + suffix;
65+
}
66+
}

‎src/test/java/com/example/solidconnection/siteuser/repository/SiteUserRepositoryTest.java‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,4 +88,22 @@ class 닉네임은_중복될_수_없다 {
8888
.isInstanceOf(DataIntegrityViolationException.class);
8989
}
9090
}
91+
92+
@Nested
93+
class 인증된_학교_이메일은_중복될_수_없다 {
94+
95+
@Test
96+
void 인증된_학교_이메일이_동일한_사용자를_저장하면_예외가_발생한다() {
97+
// given
98+
SiteUser user1 = createSiteUser("email1", "nickname1", AuthType.KAKAO);
99+
SiteUser user2 = createSiteUser("email2", "nickname2", AuthType.KAKAO);
100+
user1.verifySchool(1L, "test@inha.edu");
101+
user2.verifySchool(1L, "test@inha.edu");
102+
siteUserRepository.save(user1);
103+
104+
// when, then
105+
assertThatCode(() -> siteUserRepository.saveAndFlush(user2))
106+
.isInstanceOf(DataIntegrityViolationException.class);
107+
}
108+
}
91109
}

0 commit comments

Comments
 (0)