Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: 인증시 학교 이메일 인증 메일 전송 여부 판단 로직 추가 #513

Merged
merged 11 commits into from
Jul 31, 2024
Merged
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ dependencies {

// Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
testImplementation group: 'org.testcontainers', name: 'testcontainers'
seulgi99 marked this conversation as resolved.
Show resolved Hide resolved

// Querydsl
implementation 'com.querydsl:querydsl-jpa:5.1.0:jakarta'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ public void onMessageReceived(MessageReceivedEvent event) {
Message message = event.getMessage();
String content = message.getContentRaw(); // get only textual content of message

log.info("Message from {} in {}: {}", author.getName(), channel.getName(), message.getContentDisplay());
log.info("Message of {} in {}: {}", author.getName(), channel.getName(), message.getContentDisplay());

if (author.isBot()) return;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,13 @@

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.HongikUnivEmailValidator;
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.common.constant.JwtConstant;
import com.gdschongik.gdsc.global.property.JwtProperty;
import com.gdschongik.gdsc.global.util.MemberUtil;
import com.gdschongik.gdsc.global.util.email.EmailVerificationTokenUtil;
import com.gdschongik.gdsc.global.util.email.MailSender;
Expand All @@ -21,12 +26,14 @@
public class UnivEmailVerificationLinkSendService {

private final MemberRepository memberRepository;
private final UnivEmailVerificationRepository univEmailVerificationRepository;

private final MailSender mailSender;
private final HongikUnivEmailValidator hongikUnivEmailValidator;
private final EmailVerificationTokenUtil emailVerificationTokenUtil;
private final VerificationLinkUtil verificationLinkUtil;
private final MemberUtil memberUtil;
private final JwtProperty jwtProperty;

public static final Duration VERIFICATION_TOKEN_TIME_TO_LIVE = Duration.ofMinutes(30);

Expand All @@ -50,14 +57,25 @@ public void send(String univEmail) {
String verificationToken = generateVerificationToken(univEmail);
String verificationLink = verificationLinkUtil.createLink(verificationToken);
String mailContent = writeMailContentWithVerificationLink(verificationLink);

mailSender.send(univEmail, VERIFICATION_EMAIL_SUBJECT, mailContent);

log.info("[UnivEmailVerificationLinkSendService] 학생 인증 메일 발송: univEmail={}", univEmail);
}

private String generateVerificationToken(String univEmail) {
Long currentMemberId = memberUtil.getCurrentMemberId();
return emailVerificationTokenUtil.generateEmailVerificationToken(currentMemberId, univEmail);
final Member currentMember = memberUtil.getCurrentMember();
String verificationToken =
emailVerificationTokenUtil.generateEmailVerificationToken(currentMember.getId(), univEmail);

JwtProperty.TokenProperty emailVerificationTokenProperty =
jwtProperty.getToken().get(JwtConstant.EMAIL_VERIFICATION_TOKEN);

UnivEmailVerification univEmailVerification = UnivEmailVerification.of(
currentMember.getId(), verificationToken, emailVerificationTokenProperty.expirationTime());
univEmailVerificationRepository.save(univEmailVerification);

return verificationToken;
}

private String writeMailContentWithVerificationLink(String verificationLink) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.gdschongik.gdsc.domain.email.application;

import com.gdschongik.gdsc.domain.email.dao.UnivEmailVerificationRepository;
import com.gdschongik.gdsc.domain.email.domain.HongikUnivEmailValidator;
import com.gdschongik.gdsc.domain.email.domain.UnivEmailVerification;
import com.gdschongik.gdsc.domain.email.dto.request.EmailVerificationTokenDto;
import com.gdschongik.gdsc.domain.email.dto.request.UnivEmailVerificationRequest;
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.email.EmailVerificationTokenUtil;
import java.util.Optional;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
Expand All @@ -18,6 +22,8 @@ public class UnivEmailVerificationService {

private final EmailVerificationTokenUtil emailVerificationTokenUtil;
private final MemberRepository memberRepository;
private final UnivEmailVerificationRepository univEmailVerificationRepository;
private final HongikUnivEmailValidator hongikUnivEmailValidator;

@Transactional
public void verifyMemberUnivEmail(UnivEmailVerificationRequest request) {
Expand All @@ -26,8 +32,19 @@ public void verifyMemberUnivEmail(UnivEmailVerificationRequest request) {
member.completeUnivEmailVerification(emailVerificationToken.email());
}

public Optional<UnivEmailVerification> getUnivEmailVerificationFromRedis(Long memberId) {
seulgi99 marked this conversation as resolved.
Show resolved Hide resolved
return univEmailVerificationRepository.findById(memberId);
}

private EmailVerificationTokenDto getEmailVerificationToken(String verificationToken) {
return emailVerificationTokenUtil.parseEmailVerificationTokenDto(verificationToken);
EmailVerificationTokenDto emailVerificationTokenDto =
emailVerificationTokenUtil.parseEmailVerificationTokenDto(verificationToken);
final Optional<UnivEmailVerification> univEmailVerification =
seulgi99 marked this conversation as resolved.
Show resolved Hide resolved
getUnivEmailVerificationFromRedis(emailVerificationTokenDto.memberId());

hongikUnivEmailValidator.validateUnivEmailVerification(univEmailVerification, verificationToken);

return emailVerificationTokenDto;
}

private Member getMemberById(Long id) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<UnivEmailVerification, Long> {}
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

import com.gdschongik.gdsc.global.annotation.DomainService;
import com.gdschongik.gdsc.global.exception.CustomException;
import java.util.Optional;

@DomainService
public class HongikUnivEmailValidator {
Expand All @@ -23,4 +24,18 @@ public void validateSendUnivEmailVerificationLink(String email, boolean isUnivEm
throw new CustomException(UNIV_EMAIL_ALREADY_SATISFIED);
}
}

/**
* redis 안의 존재하는 메일인증 정보로 검증
* 1. 토큰이 비었는데 인증하려할 시 에러 (인증메일을 보내지 않았거나, 만료된 경우)
* 2. 토큰이 redis에 저장된 토큰과 다르면 만료되었다는 에러 (메일 여러번 보낸 경우)
*/
public void validateUnivEmailVerification(
Optional<UnivEmailVerification> optionalUnivEmailVerification, String currentToken) {
if (optionalUnivEmailVerification.isEmpty()) {
throw new CustomException(EMAIL_NOT_SENT);
} else if (!optionalUnivEmailVerification.get().getVerificationToken().equals(currentToken)) {
seulgi99 marked this conversation as resolved.
Show resolved Hide resolved
throw new CustomException(EXPIRED_EMAIL_VERIFICATION_TOKEN);
}
}
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,36 @@
package com.gdschongik.gdsc.domain.email.domain;

import lombok.AllArgsConstructor;
import lombok.AccessLevel;
import lombok.Builder;
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 {
seulgi99 marked this conversation as resolved.
Show resolved Hide resolved

@Id
private String verificationCode;

private String univEmail;

private Long memberId;

private String verificationToken;

@TimeToLive
private long timeToLiveInSeconds;
private long ttl;

@Builder(access = AccessLevel.PRIVATE)
private UnivEmailVerification(Long memberId, String verificationToken, long ttl) {
this.memberId = memberId;
this.verificationToken = verificationToken;
this.ttl = ttl;
}

public static UnivEmailVerification of(Long memberId, String verificationToken, long ttl) {
return UnivEmailVerification.builder()
.memberId(memberId)
.verificationToken(verificationToken)
.ttl(ttl)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
package com.gdschongik.gdsc.domain.member.application;

import static com.gdschongik.gdsc.global.exception.ErrorCode.*;
import static com.gdschongik.gdsc.global.exception.ErrorCode.FORBIDDEN;
import static com.gdschongik.gdsc.global.exception.ErrorCode.MEMBER_NOT_FOUND;
seulgi99 marked this conversation as resolved.
Show resolved Hide resolved

import com.gdschongik.gdsc.domain.auth.application.JwtService;
import com.gdschongik.gdsc.domain.auth.dto.AccessTokenDto;
import com.gdschongik.gdsc.domain.auth.dto.RefreshTokenDto;
import com.gdschongik.gdsc.domain.email.application.UnivEmailVerificationService;
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.domain.member.dto.UnivVerificationStatus;
import com.gdschongik.gdsc.domain.member.dto.request.BasicMemberInfoRequest;
import com.gdschongik.gdsc.domain.member.dto.request.MemberTokenRequest;
import com.gdschongik.gdsc.domain.member.dto.response.MemberBasicInfoResponse;
Expand All @@ -33,6 +37,7 @@ public class OnboardingMemberService {
private final MemberUtil memberUtil;
private final OnboardingRecruitmentService onboardingRecruitmentService;
private final MembershipService membershipService;
private final UnivEmailVerificationService univEmailVerificationService;
private final JwtService jwtService;
private final MemberRepository memberRepository;
private final EnvironmentUtil environmentUtil;
Expand Down Expand Up @@ -61,11 +66,13 @@ public MemberBasicInfoResponse getMemberBasicInfo() {
}

public MemberDashboardResponse getDashboard() {
Member currentMember = memberUtil.getCurrentMember();
RecruitmentRound currentRecruitmentRound = onboardingRecruitmentService.findCurrentRecruitmentRound();
Optional<Membership> myMembership = membershipService.findMyMembership(currentMember, currentRecruitmentRound);
final Member member = memberUtil.getCurrentMember();
final RecruitmentRound currentRecruitmentRound = onboardingRecruitmentService.findCurrentRecruitmentRound();
final Optional<Membership> myMembership = membershipService.findMyMembership(member, currentRecruitmentRound);
UnivVerificationStatus univVerificationStatus = determineUnivVerificationStatus(member);

return MemberDashboardResponse.from(currentMember, currentRecruitmentRound, myMembership.orElse(null));
return MemberDashboardResponse.of(
member, univVerificationStatus, currentRecruitmentRound, myMembership.orElse(null));
}

public MemberTokenResponse createTemporaryToken(MemberTokenRequest request) {
Expand All @@ -86,4 +93,16 @@ private void validateProfile() {
throw new CustomException(FORBIDDEN);
}
}

private UnivVerificationStatus determineUnivVerificationStatus(Member member) {
if (member.getAssociateRequirement().isUnivSatisfied()) {
return UnivVerificationStatus.SATISFIED;
} else {
final Optional<UnivEmailVerification> univEmailVerification =
seulgi99 marked this conversation as resolved.
Show resolved Hide resolved
univEmailVerificationService.getUnivEmailVerificationFromRedis(member.getId());
return univEmailVerification.isPresent()
? UnivVerificationStatus.IN_PROGRESS
: UnivVerificationStatus.PENDING;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public void verifyInfo() {

// 데이터 전달 로직

private boolean isUnivSatisfied() {
public boolean isUnivSatisfied() {
return univStatus == SATISFIED;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,17 +1,25 @@
package com.gdschongik.gdsc.domain.member.dto;

import com.gdschongik.gdsc.domain.member.domain.AssociateRequirement;
import com.gdschongik.gdsc.domain.common.model.RequirementStatus;
import com.gdschongik.gdsc.domain.member.domain.Department;
import com.gdschongik.gdsc.domain.member.domain.Member;
import com.gdschongik.gdsc.domain.member.domain.MemberRole;
import com.gdschongik.gdsc.global.util.formatter.PhoneFormatter;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.Optional;

public record MemberFullDto(
Long memberId, MemberRole role, MemberBasicInfoDto basicInfo, AssociateRequirement associateRequirement) {
public static MemberFullDto from(Member member) {
Long memberId,
@Schema(description = "멤버 역할", implementation = MemberRole.class) MemberRole role,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

개인적인 궁금함인데 implementation = MemberRole.class 와 같이 지정하면 어떤 효과가 있나요?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

프론트에서 사용하는지는 모르겠지만, 스웨거 api 문서를 통한 api스펙 자동적용 툴들 (swagger-typescript-api, auto-generated 이런것들)사용할때 해당 객체를 �인식하게 해줘요!

저는 그냥 저렇게 항상 구현하도록 하는게 익숙해져서 그렇긴한데, 만약 안쓴다면 지워도 좋을거같아요!

어떻게생각하시나여

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

저도 쓰는지 안쓰는지는 잘 몰라서 ㅎㅎ; 따로 요청사항 없는거 보면 안쓰는 것 같네요
일관성 측면에서 그냥 지우는게 나을듯 합니다

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

음 재현님 이거는 제가 이슈파서 따로 진행하는게 좋을거같아요! 이번 pr말고도 스터디쪾도 있어서요!!

@Schema(description = "회원정보", implementation = MemberBasicInfoDto.class) MemberBasicInfoDto basicInfo,
@Schema(description = "인증상태정보", implementation = MemberAssociateRequirementDto.class)
MemberAssociateRequirementDto associateRequirement) {
public static MemberFullDto of(Member member, UnivVerificationStatus univVerificationStatus) {
return new MemberFullDto(
member.getId(), member.getRole(), MemberBasicInfoDto.from(member), member.getAssociateRequirement());
member.getId(),
member.getRole(),
MemberBasicInfoDto.from(member),
MemberAssociateRequirementDto.of(member, univVerificationStatus));
}

record MemberBasicInfoDto(
Expand All @@ -37,4 +45,20 @@ public static MemberBasicInfoDto from(Member member) {
member.getNickname());
}
}

public record MemberAssociateRequirementDto(
@Schema(description = "학교메일 인증상태", implementation = UnivVerificationStatus.class)
UnivVerificationStatus univStatus,
@Schema(description = "디스코드 인증상태", implementation = RequirementStatus.class)
RequirementStatus discordStatus,
@Schema(description = "bevy 인증상태", implementation = RequirementStatus.class) RequirementStatus bevyStatus,
@Schema(description = "회원정보 입력상태", implementation = RequirementStatus.class) RequirementStatus infoStatus) {
public static MemberAssociateRequirementDto of(Member member, UnivVerificationStatus univVerificationStatus) {
return new MemberAssociateRequirementDto(
univVerificationStatus,
member.getAssociateRequirement().getDiscordStatus(),
member.getAssociateRequirement().getBevyStatus(),
member.getAssociateRequirement().getInfoStatus());
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.gdschongik.gdsc.domain.member.dto;

import lombok.AllArgsConstructor;
import lombok.Getter;

@Getter
@AllArgsConstructor
public enum UnivVerificationStatus {
PENDING("PENDING"),
IN_PROGRESS("IN_PROGRESS"),
SATISFIED("SATISFIED");

private final String value;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.gdschongik.gdsc.domain.member.domain.Member;
import com.gdschongik.gdsc.domain.member.dto.MemberFullDto;
import com.gdschongik.gdsc.domain.member.dto.UnivVerificationStatus;
import com.gdschongik.gdsc.domain.membership.domain.Membership;
import com.gdschongik.gdsc.domain.membership.dto.MembershipFullDto;
import com.gdschongik.gdsc.domain.recruitment.domain.RecruitmentRound;
Expand All @@ -12,10 +13,13 @@ public record MemberDashboardResponse(
MemberFullDto member,
RecruitmentRoundFullDto currentRecruitmentRound,
@Nullable MembershipFullDto currentMembership) {
public static MemberDashboardResponse from(
Member member, RecruitmentRound currentRecruitmentRound, Membership currentMembership) {
public static MemberDashboardResponse of(
Member member,
UnivVerificationStatus univVerificationStatus,
RecruitmentRound currentRecruitmentRound,
Membership currentMembership) {
return new MemberDashboardResponse(
MemberFullDto.from(member),
MemberFullDto.of(member, univVerificationStatus),
RecruitmentRoundFullDto.from(currentRecruitmentRound),
currentMembership == null ? null : MembershipFullDto.from(currentMembership));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ public enum ErrorCode {
UNIV_EMAIL_DOMAIN_MISMATCH(HttpStatus.BAD_REQUEST, "재학생 메일의 도메인이 맞지 않습니다."),
MESSAGING_EXCEPTION(HttpStatus.BAD_REQUEST, "수신자 이메일이 올바르지 않습니다."),
VERIFICATION_CODE_NOT_FOUND(HttpStatus.NOT_FOUND, "재학생 인증 코드가 존재하지 않습니다."),
EMAIL_NOT_SENT(HttpStatus.BAD_REQUEST, "재학생 인증 메일이 발송되지 않았습니다."),
EXPIRED_EMAIL_VERIFICATION_TOKEN(HttpStatus.BAD_REQUEST, "이메일 인증 토큰이 만료되었습니다."),
INVALID_EMAIL_VERIFICATION_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 이메일 인증 토큰입니다."),

Expand Down
26 changes: 17 additions & 9 deletions src/test/java/com/gdschongik/gdsc/config/TestRedisConfig.java
Original file line number Diff line number Diff line change
@@ -1,12 +1,20 @@
package com.gdschongik.gdsc.config;

import com.gdschongik.gdsc.global.config.RedisConfig;
import com.gdschongik.gdsc.global.property.RedisProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.test.context.TestConfiguration;
import org.springframework.context.annotation.Import;
import org.junit.jupiter.api.extension.BeforeAllCallback;
import org.junit.jupiter.api.extension.ExtensionContext;
import org.testcontainers.containers.GenericContainer;
import org.testcontainers.utility.DockerImageName;

@TestConfiguration
@EnableConfigurationProperties({RedisProperty.class})
@Import({RedisConfig.class})
public class TestRedisConfig {}
public class TestRedisConfig implements BeforeAllCallback {
private static final String REDIS_IMAGE = "redis:alpine";
private static final int REDIS_PORT = 6379;
private GenericContainer redis;

@Override
public void beforeAll(ExtensionContext context) {
redis = new GenericContainer(DockerImageName.parse(REDIS_IMAGE)).withExposedPorts(REDIS_PORT);
redis.start();
System.setProperty("spring.data.redis.host", redis.getHost());
System.setProperty("spring.data.redis.port", String.valueOf(redis.getMappedPort(REDIS_PORT)));
}
}
Loading