diff --git a/build.gradle b/build.gradle index 8ba3f7437..5e53324fe 100644 --- a/build.gradle +++ b/build.gradle @@ -67,6 +67,9 @@ dependencies { // Spring AOP implementation 'org.springframework.boot:spring-boot-starter-aop' + + // Mail + implementation 'org.springframework.boot:spring-boot-starter-mail' } tasks.named('test') { diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/api/OnboardingUnivEmailController.java b/src/main/java/com/gdschongik/gdsc/domain/email/api/OnboardingUnivEmailController.java new file mode 100644 index 000000000..894ffa7c0 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/email/api/OnboardingUnivEmailController.java @@ -0,0 +1,44 @@ +package com.gdschongik.gdsc.domain.email.api; + +import static com.gdschongik.gdsc.global.common.constant.EmailConstant.VERIFY_EMAIL_REQUEST_PARAMETER_KEY; + +import com.gdschongik.gdsc.domain.email.application.UnivEmailVerificationLinkSendService; +import com.gdschongik.gdsc.domain.email.application.UnivEmailVerificationService; +import com.gdschongik.gdsc.domain.email.dto.request.UnivEmailVerificationLinkSendRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Univ Email", description = "학교 인증 메일 인증 API입니다.") +@RestController +@RequestMapping("/onboarding") +@RequiredArgsConstructor +public class OnboardingUnivEmailController { + + private final UnivEmailVerificationLinkSendService univEmailVerificationLinkSendService; + private final UnivEmailVerificationService univEmailVerificationService; + + @Operation(summary = "학교 인증 메일 발송 요청", description = "학교 인증 메일 발송을 요청합니다.") + @PostMapping("/send-verify-email") + public ResponseEntity sendUnivEmailVerificationLink( + @Valid @RequestBody UnivEmailVerificationLinkSendRequest request) { + univEmailVerificationLinkSendService.send(request.univEmail()); + return ResponseEntity.ok().build(); + } + + @Operation(summary = "학교 인증 메일 인증하기", description = "학교 인증 메일을 인증합니다.") + @GetMapping("/verify-email") + public ResponseEntity sendUnivEmailVerificationLink( + @RequestParam(VERIFY_EMAIL_REQUEST_PARAMETER_KEY) String verificationCode) { + univEmailVerificationService.verifyMemberUnivEmail(verificationCode); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationLinkSendService.java b/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationLinkSendService.java new file mode 100644 index 000000000..0de91e51b --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationLinkSendService.java @@ -0,0 +1,73 @@ +package com.gdschongik.gdsc.domain.email.application; + +import static com.gdschongik.gdsc.global.common.constant.EmailConstant.VERIFICATION_EMAIL_SUBJECT; + +import com.gdschongik.gdsc.domain.email.dao.UnivEmailVerificationRepository; +import com.gdschongik.gdsc.domain.email.domain.UnivEmailVerification; +import com.gdschongik.gdsc.domain.member.dao.MemberRepository; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import com.gdschongik.gdsc.global.util.MemberUtil; +import com.gdschongik.gdsc.global.util.email.HongikUnivEmailValidator; +import com.gdschongik.gdsc.global.util.email.MailSender; +import com.gdschongik.gdsc.global.util.email.VerificationCodeGenerator; +import com.gdschongik.gdsc.global.util.email.VerificationLinkUtil; +import java.time.Duration; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class UnivEmailVerificationLinkSendService { + + private final MemberRepository memberRepository; + private final UnivEmailVerificationRepository univEmailVerificationRepository; + + private final MailSender mailSender; + private final HongikUnivEmailValidator hongikUnivEmailValidator; + private final VerificationCodeGenerator verificationCodeGenerator; + private final VerificationLinkUtil verificationLinkUtil; + private final MemberUtil memberUtil; + public static final Duration VERIFICATION_CODE_TIME_TO_LIVE = Duration.ofMinutes(10); + + private static final String NOTIFICATION_MESSAGE = "
" + + "

안녕하세요 GDSC Hongik 재학생 인증 메일입니다.


" + + "

아래의 링크를 %d분 안에 클릭해주세요.


" + + "

감사합니다.


" + + "CODE : "; + + public void send(String univEmail) { + hongikUnivEmailValidator.validate(univEmail); + validateUnivEmailNotVerified(univEmail); + + String verificationCode = verificationCodeGenerator.generate(); + String verificationLink = verificationLinkUtil.createLink(verificationCode); + String mailContent = writeMailContentWithVerificationLink(verificationLink); + mailSender.send(univEmail, VERIFICATION_EMAIL_SUBJECT, mailContent); + + saveUnivEmailVerification(univEmail, verificationCode); + } + + private void validateUnivEmailNotVerified(String univEmail) { + Optional member = memberRepository.findByUnivEmail(univEmail); + if (member.isPresent()) { + throw new CustomException(ErrorCode.UNIV_EMAIL_ALREADY_VERIFIED); + } + } + + private String writeMailContentWithVerificationLink(String verificationLink) { + return String.format(NOTIFICATION_MESSAGE, VERIFICATION_CODE_TIME_TO_LIVE.toMinutes()) + verificationLink; + } + + private void saveUnivEmailVerification(String univEmail, String verificationCode) { + Long currentMemberId = memberUtil.getCurrentMemberId(); + UnivEmailVerification univEmailVerification = new UnivEmailVerification( + verificationCode, univEmail, currentMemberId, VERIFICATION_CODE_TIME_TO_LIVE.toSeconds()); + + univEmailVerificationRepository.save(univEmailVerification); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationService.java b/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationService.java new file mode 100644 index 000000000..6cafdca0f --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/email/application/UnivEmailVerificationService.java @@ -0,0 +1,37 @@ +package com.gdschongik.gdsc.domain.email.application; + +import com.gdschongik.gdsc.domain.email.dao.UnivEmailVerificationRepository; +import com.gdschongik.gdsc.domain.email.domain.UnivEmailVerification; +import com.gdschongik.gdsc.domain.member.dao.MemberRepository; +import com.gdschongik.gdsc.domain.member.domain.Member; +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class UnivEmailVerificationService { + + private final MemberRepository memberRepository; + private final UnivEmailVerificationRepository univEmailVerificationRepository; + + @Transactional + public void verifyMemberUnivEmail(String verificationCode) { + UnivEmailVerification univEmailVerification = getUnivEmailVerification(verificationCode); + Member member = getMemberById(univEmailVerification.getMemberId()); + member.completeUnivEmailVerification(univEmailVerification.getUnivEmail()); + } + + private UnivEmailVerification getUnivEmailVerification(String verificationCode) { + return univEmailVerificationRepository + .findById(verificationCode) + .orElseThrow(() -> new CustomException(ErrorCode.VERIFICATION_CODE_NOT_FOUND)); + } + + private Member getMemberById(Long id) { + return memberRepository.findById(id).orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/dao/UnivEmailVerificationRepository.java b/src/main/java/com/gdschongik/gdsc/domain/email/dao/UnivEmailVerificationRepository.java new file mode 100644 index 000000000..b81104cd2 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/email/dao/UnivEmailVerificationRepository.java @@ -0,0 +1,6 @@ +package com.gdschongik.gdsc.domain.email.dao; + +import com.gdschongik.gdsc.domain.email.domain.UnivEmailVerification; +import org.springframework.data.repository.CrudRepository; + +public interface UnivEmailVerificationRepository extends CrudRepository {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/domain/UnivEmailVerification.java b/src/main/java/com/gdschongik/gdsc/domain/email/domain/UnivEmailVerification.java new file mode 100644 index 000000000..be0c6a6fb --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/email/domain/UnivEmailVerification.java @@ -0,0 +1,23 @@ +package com.gdschongik.gdsc.domain.email.domain; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +@Getter +@AllArgsConstructor +@RedisHash(value = "univEmailVerification") +public class UnivEmailVerification { + + @Id + private String verificationCode; + + private String univEmail; + + private Long memberId; + + @TimeToLive + private long timeToLiveInSeconds; +} diff --git a/src/main/java/com/gdschongik/gdsc/domain/email/dto/request/UnivEmailVerificationLinkSendRequest.java b/src/main/java/com/gdschongik/gdsc/domain/email/dto/request/UnivEmailVerificationLinkSendRequest.java new file mode 100644 index 000000000..98c8f4f02 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/domain/email/dto/request/UnivEmailVerificationLinkSendRequest.java @@ -0,0 +1,13 @@ +package com.gdschongik.gdsc.domain.email.dto.request; + +import static com.gdschongik.gdsc.global.common.constant.RegexConstant.HONGIK_EMAIL; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; + +public record UnivEmailVerificationLinkSendRequest( + @NotBlank + @Pattern(regexp = HONGIK_EMAIL, message = "학교 이메일은 " + HONGIK_EMAIL + " 형식이어야 합니다.") + @Schema(description = "학교 이메일", pattern = HONGIK_EMAIL) + String univEmail) {} diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberRepository.java b/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberRepository.java index a8eb0e98d..bf8c40dd1 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberRepository.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/dao/MemberRepository.java @@ -10,4 +10,6 @@ public interface MemberRepository extends JpaRepository, MemberCus boolean existsByNickname(String nickname); Optional findByDiscordUsername(String discordUsername); + + Optional findByUnivEmail(String univEmail); } diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java index 2e781cd5a..10bcc0ad6 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/Member.java @@ -207,8 +207,31 @@ public void updateMemberInfo( this.nickname = nickname; } - // 가입조건 인증 로직 + public void completeUnivEmailVerification(String univEmail) { + this.univEmail = univEmail; + requirement.updateUnivStatus(RequirementStatus.VERIFIED); + } + // private void validateStatusUpdatable() { + // if (isDeleted()) { + // throw new CustomException(MEMBER_DELETED); + // } + // if (isForbidden()) { + // throw new CustomException(MEMBER_FORBIDDEN); + // } + // } + + // private void validateUnivStatus() { + // if (this.requirement.isUnivPending()) { + // throw new CustomException(UNIV_NOT_VERIFIED); + // } + // } + + // public void grant() { + // this.role = MemberRole.USER; + // } + + // 가입조건 인증 로직 public void verifyDiscord(String discordUsername, String nickname) { validateStatusUpdatable(); diff --git a/src/main/java/com/gdschongik/gdsc/domain/member/domain/Requirement.java b/src/main/java/com/gdschongik/gdsc/domain/member/domain/Requirement.java index 0b1508a46..1a6891ba1 100644 --- a/src/main/java/com/gdschongik/gdsc/domain/member/domain/Requirement.java +++ b/src/main/java/com/gdschongik/gdsc/domain/member/domain/Requirement.java @@ -48,6 +48,10 @@ public static Requirement createRequirement() { .build(); } + public void updateUnivStatus(RequirementStatus univStatus) { + this.univStatus = univStatus; + } + public void updatePaymentStatus(RequirementStatus status) { this.paymentStatus = status; } diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/EmailConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/EmailConstant.java new file mode 100644 index 000000000..abc4e3067 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/EmailConstant.java @@ -0,0 +1,13 @@ +package com.gdschongik.gdsc.global.common.constant; + +public class EmailConstant { + + public static final String VERIFY_EMAIL_API_ENDPOINT = "/onboarding/verify-email?%s="; + public static final String VERIFY_EMAIL_REQUEST_PARAMETER_KEY = "token"; + public static final String HONGIK_UNIV_MAIL_DOMAIN = "@g.hongik.ac.kr"; + public static final String SENDER_PERSONAL = "GDSC Hongik 운영팀"; + public static final String SENDER_ADDRESS = "gdsc.hongik@gmail.com"; + public static final String VERIFICATION_EMAIL_SUBJECT = "GDSC Hongik 이메일 인증 코드입니다."; + + private EmailConstant() {} +} diff --git a/src/main/java/com/gdschongik/gdsc/global/common/constant/RegexConstant.java b/src/main/java/com/gdschongik/gdsc/global/common/constant/RegexConstant.java index 21c83beb9..d557d610d 100644 --- a/src/main/java/com/gdschongik/gdsc/global/common/constant/RegexConstant.java +++ b/src/main/java/com/gdschongik/gdsc/global/common/constant/RegexConstant.java @@ -1,11 +1,14 @@ package com.gdschongik.gdsc.global.common.constant; public class RegexConstant { + public static final String STUDENT_ID = "^[A-C]{1}[0-9]{6}$"; public static final String PHONE = "^010-[0-9]{4}-[0-9]{4}$"; public static final String PHONE_WITHOUT_HYPHEN = "^010[0-9]{8}$"; public static final String NICKNAME = "[ㄱ-ㅣ가-힣]{1,6}$"; public static final String DEPARTMENT = "^D[0-9]{3}$"; + public static final String HONGIK_EMAIL = "^[^\\W&=_'-+,<>]+(\\.[^\\W&=_'-+,<>]+)*@g\\.hongik\\.ac\\.kr$"; + private RegexConstant() {} } diff --git a/src/main/java/com/gdschongik/gdsc/global/config/JavaMailSenderConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/JavaMailSenderConfig.java new file mode 100644 index 000000000..e1b0748af --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/config/JavaMailSenderConfig.java @@ -0,0 +1,41 @@ +package com.gdschongik.gdsc.global.config; + +import com.gdschongik.gdsc.global.property.email.EmailProperty; +import com.gdschongik.gdsc.global.property.email.EmailProperty.Gmail; +import java.util.Properties; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.JavaMailSenderImpl; + +@Configuration +@RequiredArgsConstructor +public class JavaMailSenderConfig { + + private final EmailProperty emailProperty; + + @Bean + public JavaMailSender javaMailSender() { + JavaMailSenderImpl javaMailSender = new JavaMailSenderImpl(); + javaMailSender.setHost(emailProperty.getHost()); + javaMailSender.setPort(emailProperty.getPort()); + javaMailSender.setDefaultEncoding(emailProperty.getEncoding()); + + setGmailProperty(javaMailSender); + setJavaMailProperties(javaMailSender); + return javaMailSender; + } + + private void setGmailProperty(JavaMailSenderImpl javaMailSender) { + Gmail gmail = emailProperty.getGmail(); + javaMailSender.setUsername(gmail.loginEmail()); + javaMailSender.setPassword(gmail.password()); + } + + private void setJavaMailProperties(JavaMailSenderImpl javaMailSender) { + Properties properties = new Properties(); + properties.putAll(emailProperty.getJavaMailProperty()); + javaMailSender.setJavaMailProperties(properties); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java b/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java index 7f6d903ca..e53aa228e 100644 --- a/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java +++ b/src/main/java/com/gdschongik/gdsc/global/config/PropertyConfig.java @@ -4,9 +4,16 @@ import com.gdschongik.gdsc.global.property.JwtProperty; import com.gdschongik.gdsc.global.property.RedisProperty; import com.gdschongik.gdsc.global.property.SwaggerProperty; +import com.gdschongik.gdsc.global.property.email.EmailProperty; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Configuration; -@EnableConfigurationProperties({JwtProperty.class, RedisProperty.class, SwaggerProperty.class, DiscordProperty.class}) +@EnableConfigurationProperties({ + JwtProperty.class, + RedisProperty.class, + SwaggerProperty.class, + DiscordProperty.class, + EmailProperty.class +}) @Configuration public class PropertyConfig {} diff --git a/src/main/java/com/gdschongik/gdsc/global/exception/CustomException.java b/src/main/java/com/gdschongik/gdsc/global/exception/CustomException.java index fc1b0f5a1..875ae361d 100644 --- a/src/main/java/com/gdschongik/gdsc/global/exception/CustomException.java +++ b/src/main/java/com/gdschongik/gdsc/global/exception/CustomException.java @@ -11,4 +11,9 @@ public CustomException(ErrorCode errorCode) { super(errorCode.getMessage()); this.errorCode = errorCode; } + + public CustomException(ErrorCode errorCode, String errorMessage) { + super(errorMessage); + this.errorCode = errorCode; + } } diff --git a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java index 8d623c2bf..17987926f 100644 --- a/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java +++ b/src/main/java/com/gdschongik/gdsc/global/exception/ErrorCode.java @@ -18,6 +18,9 @@ public enum ErrorCode { AUTH_NOT_EXIST(HttpStatus.INTERNAL_SERVER_ERROR, "시큐리티 인증 정보가 존재하지 않습니다."), AUTH_NOT_PARSABLE(HttpStatus.INTERNAL_SERVER_ERROR, "시큐리티 인증 정보 파싱에 실패했습니다."), + // Parameter + INVALID_QUERY_PARAMETER(HttpStatus.BAD_REQUEST, "잘못된 쿼리 파라미터입니다."), + // Member MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 커뮤니티 멤버입니다."), MEMBER_DELETED(HttpStatus.CONFLICT, "탈퇴한 회원입니다."), @@ -27,15 +30,19 @@ public enum ErrorCode { MEMBER_DISCORD_USERNAME_DUPLICATE(HttpStatus.CONFLICT, "이미 등록된 디스코드 유저네임입니다."), MEMBER_NICKNAME_DUPLICATE(HttpStatus.CONFLICT, "이미 사용중인 닉네임입니다."), - // Parameter - INVALID_QUERY_PARAMETER(HttpStatus.BAD_REQUEST, "잘못된 쿼리 파라미터입니다."), - // Requirement UNIV_NOT_VERIFIED(HttpStatus.CONFLICT, "재학생 인증이 완료되지 않았습니다."), DISCORD_NOT_VERIFIED(HttpStatus.CONFLICT, "디스코드 인증이 완료되지 않았습니다."), PAYMENT_NOT_VERIFIED(HttpStatus.CONFLICT, "회비 납부가 완료되지 않았습니다."), BEVY_NOT_VERIFIED(HttpStatus.CONFLICT, "GDSC Bevy 가입이 완료되지 않았습니다."), + // Univ Email Verification + UNIV_EMAIL_ALREADY_VERIFIED(HttpStatus.CONFLICT, "이미 가입된 재학생 메일입니다."), + UNIV_EMAIL_FORMAT_MISMATCH(HttpStatus.BAD_REQUEST, "형식에 맞지 않는 재학생 메일입니다."), + UNIV_EMAIL_DOMAIN_MISMATCH(HttpStatus.BAD_REQUEST, "재학생 메일의 도메인이 맞지 않습니다."), + MESSAGING_EXCEPTION(HttpStatus.BAD_REQUEST, "수신자 이메일이 올바르지 않습니다."), + VERIFICATION_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "재학생 인증 코드가 존재하지 않습니다."), + // Discord DISCORD_INVALID_CODE_RANGE(HttpStatus.INTERNAL_SERVER_ERROR, "디스코드 인증코드는 4자리 숫자여야 합니다."), DISCORD_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "해당 유저네임으로 발급된 디스코드 인증코드가 존재하지 않습니다."), diff --git a/src/main/java/com/gdschongik/gdsc/global/property/email/EmailProperty.java b/src/main/java/com/gdschongik/gdsc/global/property/email/EmailProperty.java new file mode 100644 index 000000000..16ada49f8 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/property/email/EmailProperty.java @@ -0,0 +1,20 @@ +package com.gdschongik.gdsc.global.property.email; + +import java.util.Map; +import lombok.AllArgsConstructor; +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@AllArgsConstructor +@ConfigurationProperties(prefix = "email") +public class EmailProperty { + + private final Gmail gmail; + private final String host; + private final int port; + private final String encoding; + private final Map javaMailProperty; + + public record Gmail(String loginEmail, String password) {} +} diff --git a/src/main/java/com/gdschongik/gdsc/global/util/email/HongikUnivEmailValidator.java b/src/main/java/com/gdschongik/gdsc/global/util/email/HongikUnivEmailValidator.java new file mode 100644 index 000000000..fe0545b3d --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/util/email/HongikUnivEmailValidator.java @@ -0,0 +1,24 @@ +package com.gdschongik.gdsc.global.util.email; + +import static com.gdschongik.gdsc.global.common.constant.EmailConstant.HONGIK_UNIV_MAIL_DOMAIN; +import static com.gdschongik.gdsc.global.common.constant.RegexConstant.HONGIK_EMAIL; + +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class HongikUnivEmailValidator { + + public void validate(String email) { + if (!email.contains(HONGIK_UNIV_MAIL_DOMAIN)) { + throw new CustomException(ErrorCode.UNIV_EMAIL_DOMAIN_MISMATCH); + } + + if (!email.matches(HONGIK_EMAIL)) { + throw new CustomException(ErrorCode.UNIV_EMAIL_FORMAT_MISMATCH); + } + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/util/email/JavaEmailSender.java b/src/main/java/com/gdschongik/gdsc/global/util/email/JavaEmailSender.java new file mode 100644 index 000000000..e7f42c499 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/util/email/JavaEmailSender.java @@ -0,0 +1,66 @@ +package com.gdschongik.gdsc.global.util.email; + +import static com.gdschongik.gdsc.global.common.constant.EmailConstant.SENDER_ADDRESS; +import static com.gdschongik.gdsc.global.common.constant.EmailConstant.SENDER_PERSONAL; + +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import jakarta.mail.Message.RecipientType; +import jakarta.mail.MessagingException; +import jakarta.mail.internet.InternetAddress; +import jakarta.mail.internet.MimeMessage; +import java.io.UnsupportedEncodingException; +import lombok.RequiredArgsConstructor; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class JavaEmailSender implements MailSender { + + private final JavaMailSender javaMailSender; + + @Override + public void send(String recipient, String subject, String content) { + MimeMessage message = writeMimeMessage(recipient, subject, content); + javaMailSender.send(message); + } + + private MimeMessage writeMimeMessage(String recipient, String subject, String content) { + MimeMessage message = javaMailSender.createMimeMessage(); + addRecipients(message, recipient); + setMessageAttributes(message, subject, content); + return message; + } + + private void addRecipients(MimeMessage message, String recipient) { + try { + message.addRecipients(RecipientType.TO, recipient); + } catch (MessagingException e) { + throwCustomExceptionWithAdditionalMessage(ErrorCode.MESSAGING_EXCEPTION, e.getMessage()); + } + } + + private void setMessageAttributes(MimeMessage message, String subject, String content) { + try { + message.setSubject(subject); + message.setText(content, "utf-8", "html"); + message.setFrom(getInternetAddress()); + } catch (MessagingException e) { + throwCustomExceptionWithAdditionalMessage(ErrorCode.INTERNAL_SERVER_ERROR, e.getMessage()); + } + } + + private InternetAddress getInternetAddress() throws MessagingException { + try { + return new InternetAddress(SENDER_ADDRESS, SENDER_PERSONAL); + } catch (UnsupportedEncodingException unsupportedEncodingException) { + throw new MessagingException("UnsupportedEncodingException"); + } + } + + private void throwCustomExceptionWithAdditionalMessage(ErrorCode errorCode, String additionalMessage) { + String errorMessage = errorCode.getMessage() + " : " + additionalMessage; + throw new CustomException(errorCode, errorMessage); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/util/email/MailSender.java b/src/main/java/com/gdschongik/gdsc/global/util/email/MailSender.java new file mode 100644 index 000000000..e79bf6bf4 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/util/email/MailSender.java @@ -0,0 +1,6 @@ +package com.gdschongik.gdsc.global.util.email; + +public interface MailSender { + + void send(String recipient, String subject, String content); +} diff --git a/src/main/java/com/gdschongik/gdsc/global/util/email/VerificationCodeGenerator.java b/src/main/java/com/gdschongik/gdsc/global/util/email/VerificationCodeGenerator.java new file mode 100644 index 000000000..28c138d3e --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/util/email/VerificationCodeGenerator.java @@ -0,0 +1,16 @@ +package com.gdschongik.gdsc.global.util.email; + +import org.apache.commons.lang3.RandomStringUtils; +import org.springframework.stereotype.Component; + +@Component +public class VerificationCodeGenerator { + + private static final int VERIFICATION_CODE_LENGTH = 16; + private static final char RANGE_START_CHAR = '0'; + private static final char RANGE_END_CHAR = 'z'; + + public String generate() { + return RandomStringUtils.random(VERIFICATION_CODE_LENGTH, RANGE_START_CHAR, RANGE_END_CHAR, true, true); + } +} diff --git a/src/main/java/com/gdschongik/gdsc/global/util/email/VerificationLinkUtil.java b/src/main/java/com/gdschongik/gdsc/global/util/email/VerificationLinkUtil.java new file mode 100644 index 000000000..ce90b32b2 --- /dev/null +++ b/src/main/java/com/gdschongik/gdsc/global/util/email/VerificationLinkUtil.java @@ -0,0 +1,33 @@ +package com.gdschongik.gdsc.global.util.email; + +import static com.gdschongik.gdsc.global.common.constant.EmailConstant.VERIFY_EMAIL_API_ENDPOINT; +import static com.gdschongik.gdsc.global.common.constant.EmailConstant.VERIFY_EMAIL_REQUEST_PARAMETER_KEY; +import static com.gdschongik.gdsc.global.common.constant.EnvironmentConstant.Constants.DEV_ENV; +import static com.gdschongik.gdsc.global.common.constant.EnvironmentConstant.Constants.PROD_ENV; +import static com.gdschongik.gdsc.global.common.constant.UrlConstant.DEV_CLIENT_URL; +import static com.gdschongik.gdsc.global.common.constant.UrlConstant.LOCAL_VITE_CLIENT_SECURE_URL; +import static com.gdschongik.gdsc.global.common.constant.UrlConstant.PROD_CLIENT_URL; + +import com.gdschongik.gdsc.global.util.EnvironmentUtil; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class VerificationLinkUtil { + + private final EnvironmentUtil environmentUtil; + + public String createLink(String verificationCode) { + String verifyEmailApiEndpoint = String.format(VERIFY_EMAIL_API_ENDPOINT, VERIFY_EMAIL_REQUEST_PARAMETER_KEY); + return getClientUrl() + verifyEmailApiEndpoint + verificationCode; + } + + private String getClientUrl() { + return switch (environmentUtil.getCurrentProfile()) { + case PROD_ENV -> PROD_CLIENT_URL; + case DEV_ENV -> DEV_CLIENT_URL; + default -> LOCAL_VITE_CLIENT_SECURE_URL; + }; + } +} diff --git a/src/main/resources/application-email.yml b/src/main/resources/application-email.yml new file mode 100644 index 000000000..906b7ce89 --- /dev/null +++ b/src/main/resources/application-email.yml @@ -0,0 +1,26 @@ +email: + gmail: + login-email: ${GOOGLE_LOGIN_EMAIL} + password: ${GOOGLE_PASSWORD} + + host: "smtp.gmail.com" + port: 465 + encoding: "UTF-8" + + java-mail-property: + smtp-auth: + key: "mail.smtp.auth" + value: true + + socket-factory: + port: + key: "mail.smtp.socketFactory.port" + value: 465 + + fallback: + key: "mail.smtp.socketFactory.fallback" + value: false + + class-info: + key: "mail.smtp.socketFactory.class" + value: "javax.net.ssl.SSLSocketFactory" diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 544243235..8a19f8167 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -11,6 +11,7 @@ spring: - swagger - actuator - discord + - email logging: level: diff --git a/src/test/java/com/gdschongik/gdsc/domain/email/HongikUnivEmailValidatorTest.java b/src/test/java/com/gdschongik/gdsc/domain/email/HongikUnivEmailValidatorTest.java new file mode 100644 index 000000000..05bbcf978 --- /dev/null +++ b/src/test/java/com/gdschongik/gdsc/domain/email/HongikUnivEmailValidatorTest.java @@ -0,0 +1,78 @@ +package com.gdschongik.gdsc.domain.email; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.gdschongik.gdsc.global.exception.CustomException; +import com.gdschongik.gdsc.global.exception.ErrorCode; +import com.gdschongik.gdsc.global.util.email.HongikUnivEmailValidator; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public class HongikUnivEmailValidatorTest { + + @Autowired + private HongikUnivEmailValidator hongikUnivEmailValidator; + + @Test + @DisplayName("'g.hongik.ac.kr' 도메인을 가진 이메일을 검증할 수 있다.") + public void validateEmailDomainTest() { + String hongikDomainEmail = "test@g.hongik.ac.kr"; + + assertThatCode(() -> hongikUnivEmailValidator.validate(hongikDomainEmail)) + .doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource(strings = {"test@naver.com", "test@mail.hongik.ac.kr", "test@gmail.com", "test@gg.hongik.ac.kr"}) + @DisplayName("'g.hongik.ac.kr'가 아닌 도메인을 가진 이메일을 입력하면 예외를 발생시킨다.") + public void validateEmailDomainMismatchTest(String email) { + assertThatThrownBy(() -> hongikUnivEmailValidator.validate(email)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.UNIV_EMAIL_DOMAIN_MISMATCH.getMessage()); + } + + @Test + @DisplayName("Email의 '@' 앞 부분에는 연속되지 않은 점이 포함될 수 있다.") + public void validateEmailFormatWithDotsTest() { + String email = "t.e.s.t@g.hongik.ac.kr"; + + assertThatCode(() -> hongikUnivEmailValidator.validate(email)).doesNotThrowAnyException(); + } + + @ParameterizedTest + @ValueSource( + strings = { + "te&st@g.hongik.ac.kr", + "te=st@g.hongik.ac.kr", + "te_st@g.hongik.ac.kr", + "te'st@g.hongik.ac.kr", + "te-st@g.hongik.ac.kr", + "te+st@g.hongik.ac.kr", + "te,st@g.hongik.ac.kr", + "test@g.hongik.ac.kr" + }) + @DisplayName("Email의 '@' 앞 부분에 '&', '=', '_', ''', '-', '+', ',', '<', '>'가 포함되는 경우 예외를 발생시킨다.") + public void validateEmailFormatMismatchTest(String email) { + assertThatThrownBy(() -> hongikUnivEmailValidator.validate(email)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.UNIV_EMAIL_FORMAT_MISMATCH.getMessage()); + } + + @Test + @DisplayName("Email의 '@' 앞 부분에 '.'이 2개 연속 오는 경우 예외를 발생시킨다.") + public void validateEmailFormatMismatchWithDotsTest() { + String email = "te..st@g.hongik.ac.kr"; + assertThatThrownBy(() -> hongikUnivEmailValidator.validate(email)) + .isInstanceOf(CustomException.class) + .hasMessage(ErrorCode.UNIV_EMAIL_FORMAT_MISMATCH.getMessage()); + } +}