Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ public enum ErrorCode {

// school email verification
SCHOOL_EMAIL_ALREADY_VERIFIED(HttpStatus.BAD_REQUEST.value(), "이미 학교 이메일 인증이 완료되었습니다."),
SCHOOL_EMAIL_ALREADY_USED(HttpStatus.CONFLICT.value(), "이미 인증에 사용된 학교 이메일입니다."),
SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED(HttpStatus.BAD_REQUEST.value(), "지원하지 않는 학교 이메일 도메인입니다."),
SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND(HttpStatus.BAD_REQUEST.value(), "학교 이메일 인증 요청을 찾을 수 없습니다. 인증 코드 발송을 다시 요청해주세요."),
SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT(HttpStatus.BAD_REQUEST.value(), "인증 코드가 일치하지 않습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@
@UniqueConstraint(
name = "uk_site_user_nickname",
columnNames = {"nickname"}
),
@UniqueConstraint(
name = "uk_site_user_verified_school_email",
columnNames = {"verified_school_email"}
)
})
public class SiteUser extends BaseEntity {
Expand All @@ -54,6 +58,9 @@ public class SiteUser extends BaseEntity {
@Column(name = "home_university_id", nullable = true)
private Long homeUniversityId;

@Column(name = "verified_school_email", nullable = true, length = 100)
private String verifiedSchoolEmail;

@Setter
@Column(name = "profile_image_url", length = 500)
private String profileImageUrl;
Expand Down Expand Up @@ -159,8 +166,9 @@ public void updateUserStatus(UserStatus status) {
this.userStatus = status;
}

public void verifySchool(Long homeUniversityId) {
public void verifySchool(Long homeUniversityId, String verifiedSchoolEmail) {
this.homeUniversityId = homeUniversityId;
this.verifiedSchoolEmail = verifiedSchoolEmail;
}

public void becomeMentor() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ public interface SiteUserRepository extends JpaRepository<SiteUser, Long>, SiteU

boolean existsByNickname(String nickname);

boolean existsByVerifiedSchoolEmail(String verifiedSchoolEmail);

@Query("SELECT u FROM SiteUser u WHERE u.quitedAt <= :cutoffDate")
List<SiteUser> findUsersToBeRemoved(@Param("cutoffDate") LocalDate cutoffDate);

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.example.solidconnection.siteuser.service;

import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_VERIFIED;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_USED;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED;
Expand All @@ -17,9 +18,11 @@
import com.example.solidconnection.university.repository.HomeUniversityRepository;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Locale;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -46,15 +49,17 @@ public void requestSchoolEmailVerification(long siteUserId, String schoolEmail)
throw new CustomException(SCHOOL_EMAIL_ALREADY_VERIFIED);
}

String domain = extractEmailDomain(schoolEmail);
String normalizedSchoolEmail = normalizeSchoolEmail(schoolEmail);
String domain = extractEmailDomain(normalizedSchoolEmail);
HomeUniversity homeUniversity = homeUniversityRepository.findByEmailDomain(domain)
.orElseThrow(() -> new CustomException(SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED));
validateVerifiedSchoolEmailNotDuplicated(normalizedSchoolEmail);

String code = generateVerificationCode();
saveVerificationInfo(siteUserId, new SchoolVerificationInfo(schoolEmail, homeUniversity.getId(), code));
saveVerificationInfo(siteUserId, new SchoolVerificationInfo(normalizedSchoolEmail, homeUniversity.getId(), code));

try {
mailService.sendVerificationEmail(schoolEmail, code);
mailService.sendVerificationEmail(normalizedSchoolEmail, code);
} catch (Exception e) {
redisTemplate.delete(KEY_PREFIX + siteUserId);
throw e;
Expand All @@ -72,10 +77,21 @@ public void confirmSchoolEmail(long siteUserId, String code) {
throw new CustomException(SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT);
}

siteUser.verifySchool(info.getHomeUniversityId());
String verifiedSchoolEmail = normalizeSchoolEmail(info.getSchoolEmail());
validateVerifiedSchoolEmailNotDuplicated(verifiedSchoolEmail);
siteUser.verifySchool(info.getHomeUniversityId(), verifiedSchoolEmail);
flushVerifiedSchoolEmail();
redisTemplate.delete(KEY_PREFIX + siteUserId);
}

private void flushVerifiedSchoolEmail() {
try {
siteUserRepository.flush();
} catch (DataIntegrityViolationException e) {
throw new CustomException(SCHOOL_EMAIL_ALREADY_USED);
}
}

private void saveVerificationInfo(long siteUserId, SchoolVerificationInfo info) {
try {
redisTemplate.opsForValue().set(
Expand All @@ -95,15 +111,57 @@ private SchoolVerificationInfo getVerificationInfo(long siteUserId) {
throw new CustomException(SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND);
}
try {
return objectMapper.readValue(jsonInfo, SchoolVerificationInfo.class);
SchoolVerificationInfo info = objectMapper.readValue(jsonInfo, SchoolVerificationInfo.class);
validateVerificationInfo(siteUserId, info);
return info;
} catch (JsonProcessingException e) {
redisTemplate.delete(KEY_PREFIX + siteUserId);
throw new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED);
throw corruptedVerificationInfoException(siteUserId);
}
}

private void validateVerificationInfo(long siteUserId, SchoolVerificationInfo info) {
if (info == null
|| isBlank(info.getSchoolEmail())
|| !hasEmailDomain(info.getSchoolEmail())
|| info.getHomeUniversityId() == null
|| isBlank(info.getCode())
|| !homeUniversityRepository.existsById(info.getHomeUniversityId())) {
throw corruptedVerificationInfoException(siteUserId);
}
}

private boolean isBlank(String value) {
return value == null || value.isBlank();
}

private CustomException corruptedVerificationInfoException(long siteUserId) {
redisTemplate.delete(KEY_PREFIX + siteUserId);
return new CustomException(SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED);
}

private void validateVerifiedSchoolEmailNotDuplicated(String verifiedSchoolEmail) {
if (siteUserRepository.existsByVerifiedSchoolEmail(verifiedSchoolEmail)) {
throw new CustomException(SCHOOL_EMAIL_ALREADY_USED);
}
}

private String normalizeSchoolEmail(String schoolEmail) {
return schoolEmail.trim().toLowerCase(Locale.ROOT);
}

private String extractEmailDomain(String email) {
return email.substring(email.indexOf('@') + 1).toLowerCase();
if (!hasEmailDomain(email)) {
throw new CustomException(SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED);
}
return email.substring(email.indexOf('@') + 1);
}

private boolean hasEmailDomain(String email) {
if (email == null) {
return false;
}
int atIndex = email.indexOf('@');
return atIndex > 0 && atIndex < email.length() - 1;
}

private String generateVerificationCode() {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
ALTER TABLE site_user
ADD COLUMN verified_school_email VARCHAR(100) NULL,
ADD CONSTRAINT uk_site_user_verified_school_email UNIQUE (verified_school_email);
Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,22 @@ class 닉네임은_중복될_수_없다 {
.isInstanceOf(DataIntegrityViolationException.class);
}
}

@Nested
class 인증된_학교_이메일은_중복될_수_없다 {

@Test
void 인증된_학교_이메일이_동일한_사용자를_저장하면_예외가_발생한다() {
// given
SiteUser user1 = createSiteUser("email1", "nickname1", AuthType.KAKAO);
SiteUser user2 = createSiteUser("email2", "nickname2", AuthType.KAKAO);
user1.verifySchool(1L, "test@inha.edu");
user2.verifySchool(1L, "test@inha.edu");
siteUserRepository.save(user1);

// when, then
assertThatCode(() -> siteUserRepository.saveAndFlush(user2))
.isInstanceOf(DataIntegrityViolationException.class);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package com.example.solidconnection.siteuser.service;

import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_VERIFIED;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_ALREADY_USED;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_CONFIRM_REQUEST_NOT_FOUND;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_DOMAIN_NOT_SUPPORTED;
import static com.example.solidconnection.common.exception.ErrorCode.SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.BDDMockito.then;
import static org.mockito.Mockito.times;

import com.example.solidconnection.common.exception.CustomException;
import com.example.solidconnection.common.mail.MailService;
Expand All @@ -18,11 +21,13 @@
import com.example.solidconnection.support.TestContainerSpringBootTest;
import com.example.solidconnection.university.domain.HomeUniversity;
import com.example.solidconnection.university.fixture.HomeUniversityFixture;
import java.util.List;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.test.context.bean.override.mockito.MockitoBean;

@TestContainerSpringBootTest
Expand All @@ -44,6 +49,9 @@ class SchoolEmailServiceTest {
@Autowired
private SiteUserRepository siteUserRepository;

@Autowired
private RedisTemplate<String, String> redisTemplate;

@Nested
@DisplayName("학교 이메일 인증 요청")
class 학교_이메일_인증_요청 {
Expand All @@ -55,7 +63,7 @@ class 학교_이메일_인증_요청 {
SiteUser siteUser = siteUserFixture.사용자();

// when & then
schoolEmailService.requestSchoolEmailVerification(siteUser.getId(), "test@inha.edu");
schoolEmailService.requestSchoolEmailVerification(siteUser.getId(), " Test@INHA.EDU ");
then(mailService).should().sendVerificationEmail(eq("test@inha.edu"), any());
}

Expand All @@ -72,6 +80,25 @@ class 학교_이메일_인증_요청 {
.hasMessage(SCHOOL_EMAIL_ALREADY_VERIFIED.getMessage());
}

@Test
void 이미_인증에_사용된_학교_이메일이면_예외가_발생한다() {
// given
homeUniversityFixture.인하대학교();
SiteUser verifiedUser = siteUserFixture.사용자(1, "인증사용자");
SiteUser anotherUser = siteUserFixture.사용자(2, "다른사용자");
schoolEmailService.requestSchoolEmailVerification(verifiedUser.getId(), "test@inha.edu");

ArgumentCaptor<String> codeCaptor = ArgumentCaptor.forClass(String.class);
then(mailService).should().sendVerificationEmail(eq("test@inha.edu"), codeCaptor.capture());
schoolEmailService.confirmSchoolEmail(verifiedUser.getId(), codeCaptor.getValue());

// when & then
assertThatThrownBy(() ->
schoolEmailService.requestSchoolEmailVerification(anotherUser.getId(), "test@inha.edu"))
.isInstanceOf(CustomException.class)
.hasMessage(SCHOOL_EMAIL_ALREADY_USED.getMessage());
}

@Test
void 지원하지_않는_이메일_도메인은_예외가_발생한다() {
// given
Expand All @@ -94,7 +121,7 @@ class 학교_이메일_인증_확인 {
// given
HomeUniversity homeUniversity = homeUniversityFixture.인하대학교();
SiteUser siteUser = siteUserFixture.사용자();
schoolEmailService.requestSchoolEmailVerification(siteUser.getId(), "test@inha.edu");
schoolEmailService.requestSchoolEmailVerification(siteUser.getId(), " Test@INHA.EDU ");

ArgumentCaptor<String> codeCaptor = ArgumentCaptor.forClass(String.class);
then(mailService).should().sendVerificationEmail(any(), codeCaptor.capture());
Expand All @@ -106,6 +133,7 @@ class 학교_이메일_인증_확인 {
// Then
SiteUser updated = siteUserRepository.findById(siteUser.getId()).orElseThrow();
assertThat(updated.getHomeUniversityId()).isEqualTo(homeUniversity.getId());
assertThat(updated.getVerifiedSchoolEmail()).isEqualTo("test@inha.edu");
}

@Test
Expand Down Expand Up @@ -133,5 +161,45 @@ class 학교_이메일_인증_확인 {
.isInstanceOf(CustomException.class)
.hasMessage(SCHOOL_EMAIL_CONFIRM_CODE_DIFFERENT.getMessage());
}

@Test
void 인증_정보가_손상되면_예외가_발생하고_인증_정보가_삭제된다() {
// given
HomeUniversity homeUniversity = homeUniversityFixture.인하대학교();
SiteUser siteUser = siteUserFixture.사용자();
String key = "school-email:" + siteUser.getId();
redisTemplate.opsForValue().set(
key,
"{\"homeUniversityId\":" + homeUniversity.getId() + ",\"code\":\"123456\"}"
);

// when & then
assertThatThrownBy(() ->
schoolEmailService.confirmSchoolEmail(siteUser.getId(), "123456"))
.isInstanceOf(CustomException.class)
.hasMessage(SCHOOL_EMAIL_VERIFICATION_INFO_CORRUPTED.getMessage());
assertThat(redisTemplate.opsForValue().get(key)).isNull();
}

@Test
void 같은_학교_이메일로_인증_코드를_받았더라도_먼저_인증한_사용자가_있으면_나중_인증은_예외가_발생한다() {
// given
homeUniversityFixture.인하대학교();
SiteUser firstUser = siteUserFixture.사용자(1, "첫번째사용자");
SiteUser secondUser = siteUserFixture.사용자(2, "두번째사용자");
schoolEmailService.requestSchoolEmailVerification(firstUser.getId(), "test@inha.edu");
schoolEmailService.requestSchoolEmailVerification(secondUser.getId(), "test@inha.edu");

ArgumentCaptor<String> codeCaptor = ArgumentCaptor.forClass(String.class);
then(mailService).should(times(2)).sendVerificationEmail(eq("test@inha.edu"), codeCaptor.capture());
List<String> codes = codeCaptor.getAllValues();
schoolEmailService.confirmSchoolEmail(firstUser.getId(), codes.get(0));

// when & then
assertThatThrownBy(() ->
schoolEmailService.confirmSchoolEmail(secondUser.getId(), codes.get(1)))
.isInstanceOf(CustomException.class)
.hasMessage(SCHOOL_EMAIL_ALREADY_USED.getMessage());
}
}
}
Loading