Skip to content

Commit

Permalink
Merge pull request #32 from saessagMarket/feat/#30-email-verification…
Browse files Browse the repository at this point in the history
…-authentication

이메일 인증 및 중복 검사 구현
  • Loading branch information
ahyeonkong authored Jan 19, 2025
2 parents 7a12c63 + 68e2950 commit 1978965
Show file tree
Hide file tree
Showing 18 changed files with 385 additions and 50 deletions.
1 change: 1 addition & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-mail' // SMTP
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package com.market.saessag.domain.email.dto;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class EmailRequest { // 이메일 인증 요청 DTO
private String email; // 인증을 요청할 이메일 주소

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.market.saessag.domain.email.dto;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class EmailVerificationRequest { // 이메일 인증 코드 확인 DTO
private String email; // 인증할 이메일 주소
private String code; // 사용자가 입력한 인증 코드

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.market.saessag.domain.email.service;

import com.market.saessag.domain.user.repository.UserRepository;
import com.market.saessag.global.exception.*;
import com.market.saessag.global.util.EmailVerification;
import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage;
import lombok.RequiredArgsConstructor;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.Map;
import java.util.Random;
import java.util.concurrent.ConcurrentHashMap;

@Service
@RequiredArgsConstructor
public class EmailService {
private final JavaMailSender mailSender; // 이메일 발송을 위한 스프링 제공 인터페이스
private final UserRepository userRepository;
private final Map<String, EmailVerification> verificationStore = new ConcurrentHashMap<>(); // 이메일 인증 정보를 저장하는 동시성 지원 Map (Key: 이메일, Value: 인증정보)

/*
sendVerificationEmail(): 이메일 인증 코드를 생성하고 발송하는 메서드
*/
public void sendVerificationEmail(String toEmail) {
try {
// 이메일 중복 확인
if (userRepository.existsByEmail(toEmail)) {
throw new CustomException(ErrorCode.DUPLICATE_EMAIL); // 이미 가입된 이메일인 경우
}

// 6자리 랜덤 인증 코드 생성
String verificationCode = generateVerificationCode();

// 5분 후 만료되는 인증 정보 생성 및 저장
LocalDateTime expirationTime = LocalDateTime.now().plusMinutes(5);
verificationStore.put(toEmail, new EmailVerification(verificationCode, expirationTime));

// 이메일 메시지 생성
MimeMessage message = mailSender.createMimeMessage();
MimeMessageHelper helper = new MimeMessageHelper(message, true);
helper.setTo(toEmail);
helper.setSubject("[새싹마켓] 회원가입 인증번호 안내");
helper.setText(createEmailContent(verificationCode), true); // true는 HTML 형식 사용을 의미

// 이메일 발송
mailSender.send(message);

} catch (MessagingException e) { // 이메일 발송 실패 시 저장된 인증 정보 제거
verificationStore.remove(toEmail);
throw new CustomException(ErrorCode.INVALID_VERIFICATION_CODE); // 이메일 발송 실패
}
}

/*
verifyCode(): 인증 코드를 검증하는 메서드
*/
public void verifyCode(String email, String code) { // (이메일 주소, 사용자가 입력한 인증 코드)
// 저장된 인증 정보 조회
EmailVerification verification = verificationStore.get(email);
if (verification == null) {
throw new CustomException(ErrorCode.INVALID_VERIFICATION_CODE); // 잘못된 인증 코드이거나 인증 정보가 없는 경우
}

// 만료 여부 확인
if (verification.isExpired()) {
verificationStore.remove(email);
throw new CustomException(ErrorCode.VERIFICATION_EXPIRED); // 인증 코드가 만료된 경우
}

// 인증 코드 일치 여부 확인
if (!verification.getCode().equals(code)) {
throw new CustomException(ErrorCode.INVALID_VERIFICATION_CODE); // 잘못된 인증 코드이거나 인증 정보가 없는 경우
}

// 인증 완료 처리
verification.verify();
}

/*
isEmailVerified(): 이메일 인증 완료 여부를 확인하는 메서드
*/
public boolean isEmailVerified(String email) {
EmailVerification verification = verificationStore.get(email);
if (verification == null) {
throw new CustomException(ErrorCode.EMAIL_NOT_VERIFIED);
}
return verification.isVerified(); // 인증 완료 여부
}

/*
generateVerificationCode(): 6자리 랜덤 인증 코드를 생성하는 메서드
*/
private String generateVerificationCode() {
return String.format("%06d", new Random().nextInt(1000000));
}

/*
createEmailContent(): 이메일 본문 HTML을 생성하는 메서드
*/
private String createEmailContent(String code) {
return String.format("""
<div style='text-align: center; margin: 30px;'>
<h3> saessagMarket </h3>
<h2>이메일 인증 코드</h2>
<p> 본 메일은 saessagMarket 회원가입을 위한 이메일 인증입니다.</p>
<p> 아래의 인증 코드를 입력하여 본인확인을 해주시기 바랍니다.</p>
<div style='font-size: 24px; font-weight: bold; margin: 20px;'>%s</div>
<p>이 코드는 5분 동안 유효합니다.</p>
</div>
""", code);
}
/*
cleanupExpiredCodes(): 만료된 인증 정보를 정리하는 스케줄링 메서드
*/
@Scheduled(fixedRate = 300000) // 5분(300000ms)마다 자동 실행
public void cleanupExpiredCodes() {
verificationStore.entrySet().removeIf(entry -> entry.getValue().isExpired());
}

}
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
package com.market.saessag.domain.user.controller;

import com.market.saessag.domain.email.dto.EmailRequest;
import com.market.saessag.domain.email.dto.EmailVerificationRequest;
import com.market.saessag.domain.email.service.EmailService;
import com.market.saessag.domain.user.service.SignUpService;
import com.market.saessag.domain.user.dto.SignUpRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
Expand All @@ -15,15 +18,30 @@
@RequestMapping("/api")
public class SignUpController {

@Autowired
private SignUpService signUpService;
// 현재는 필드 주입 방식이지만 나중에 생성자 주입 방식으로 바꾸면 좋을듯
private final SignUpService signUpService;
private final EmailService emailService;

@PostMapping("/sign-up")
public ResponseEntity<Void> signUp(@RequestBody SignUpRequest signUpRequest){
signUpService.signUpProcess(signUpRequest);
// 이메일 중복 확인 및 인증 코드 발송
@PostMapping("/sign-up/email/verify")
public ResponseEntity<?> verifyEmail(@RequestBody @Validated EmailRequest emailRequest) {
// 이메일 중복 확인 후 인증 코드 발송
emailService.sendVerificationEmail(emailRequest.getEmail());
return ResponseEntity.ok("인증 메일이 발송되었습니다."); // 응답 포맷 통일 필요함
}

return ResponseEntity.ok().build();
// 인증 코드 확인
@PostMapping("/sign-up/email/confirm")
public ResponseEntity<?> confirmEmail(@RequestBody @Validated EmailVerificationRequest request) {
emailService.verifyCode(request.getEmail(), request.getCode());
return ResponseEntity.ok("이메일 인증이 완료되었습니다."); // 응답 포맷 통일 필요함
}

// 회원가입
@PostMapping("/sign-up")
public ResponseEntity<?> signUp(@RequestBody @Validated SignUpRequest signUpRequest) {
// 이메일 인증 여부 확인 후 회원가입 진행
signUpService.signUp(signUpRequest);
return ResponseEntity.ok("회원가입이 완료되었습니다."); // 응답 포맷 통일 필요함
}

}
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
// 회원가입
package com.market.saessag.domain.user.dto;

import lombok.AccessLevel;
import lombok.Getter;
import lombok.Setter;
import lombok.NoArgsConstructor;

@Setter
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class SignUpRequest {

// 이메일 인증은 어떻게 할 것인가?
private String email;
private String email; // 가입할 이메일
private String password;
private String nickname;

Expand Down
25 changes: 20 additions & 5 deletions src/main/java/com/market/saessag/domain/user/entity/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,13 @@
import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Setter
@Getter
@NoArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Table(name = "users")
public class User {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
Expand All @@ -20,19 +20,34 @@ public class User {
@Column(nullable = false)
private String password;

@Column
private String profileUrl;

@Column(nullable = false)
private String nickname;

private String role; // 권한
@Column(nullable = false)
private String role; // 사용자 권한

@Column(name = "email_verified")
private boolean emailVerified = false; // 이메일 인증 여부

@Column(name = "created_at")
private LocalDateTime createdAt; // 생성 시간

@Column(name = "updated_at")
private LocalDateTime updatedAt; // 수정 시간

@Builder
public User(String email, String password, String profileUrl, String nickname) {
public User(String email, String password, String profileUrl, String nickname, String role) {
this.email = email;
this.password = password;
this.profileUrl = profileUrl;
this.role = role;
this.emailVerified = true; // 회원가입 시점에는 이미 인증이 완료된 상태
this.nickname = nickname;
this.createdAt = LocalDateTime.now().withNano(0);
this.updatedAt = LocalDateTime.now().withNano(0);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,10 @@
import com.market.saessag.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

public interface UserRepository extends JpaRepository<User, Long> {
User findByEmail(String email);
boolean existsByEmail(String email);
/*
existsByEmail(): 이메일 중복 검증 방법
특정 email 필드가 존재하면 true, 존재하지 않으면 false 리턴
-> SignUp 서비스 단에서 검증
*/
import java.util.Optional;

public interface UserRepository extends JpaRepository<User, Long> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email); // 이메일 중복 검증 방법
User findByNickname(String nickname);
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import java.util.Optional;

@Service
@RequiredArgsConstructor
public class SignInService {
Expand All @@ -18,8 +20,8 @@ public class SignInService {

@Transactional
public SignInResponse signIn(SignInRequest signInRequest) {
User user = userRepository.findByEmail(signInRequest.getEmail());

Optional<User> userOptional = userRepository.findByEmail(signInRequest.getEmail());
User user = userOptional.get();
validateUserExists(signInRequest.getEmail()); // 이메일 검증
validatePassword(signInRequest.getPassword(), user.getPassword()); // 비밀번호 검증

Expand Down
Original file line number Diff line number Diff line change
@@ -1,38 +1,48 @@
// 회원가입 처리
package com.market.saessag.domain.user.service;

import com.market.saessag.domain.email.service.EmailService;
import com.market.saessag.domain.user.dto.SignUpRequest;
import com.market.saessag.domain.user.entity.User;
import com.market.saessag.domain.user.repository.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import com.market.saessag.global.exception.CustomException;
import com.market.saessag.global.exception.ErrorCode;
import jakarta.transaction.Transactional;
import lombok.RequiredArgsConstructor;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class SignUpService {

@Autowired
private UserRepository userRepository;
// 현재는 필드 주입 방식이지만 나중에 생성자 주입 방식으로 바꾸면 좋을듯
private final UserRepository userRepository;
private final EmailService emailService;
private final BCryptPasswordEncoder bCryptPasswordEncoder;

@Autowired
private BCryptPasswordEncoder bCryptPasswordEncoder;
@Transactional
public void signUp(SignUpRequest signUpRequest) {
String email = signUpRequest.getEmail();

public void signUpProcess(SignUpRequest signUpRequest){
// 1. 이메일 인증 여부 먼저 확인
if (!emailService.isEmailVerified(email)) {
throw new CustomException(ErrorCode.EMAIL_NOT_VERIFIED); // 이메일 미인증 시
}

// DB에 이미 동일한 email을 가진 회원이 존재하는지 검증하는 로직
boolean isUser = userRepository.existsByEmail(signUpRequest.getEmail());
if(isUser){
return; // 이미 회원이 존재하면 강제 return
// 2. 이메일 중복 확인
if (userRepository.existsByEmail(email)) {
throw new CustomException(ErrorCode.DUPLICATE_EMAIL); // 중복 이메일
}

// 회원이 존재하지 않으면 아래 로직 수행
User user = new User();
user.setEmail(signUpRequest.getEmail());
user.setPassword(bCryptPasswordEncoder.encode(signUpRequest.getPassword())); // 비밀번호 암호화
user.setNickname(signUpRequest.getNickname());
user.setRole("ROLE_USER");

// 3. 회원가입 진행
User user = User.builder()
.email(email)
.password(bCryptPasswordEncoder.encode(signUpRequest.getPassword()))
.nickname(signUpRequest.getNickname())
.role("ROLE_USER")
.build();

userRepository.save(user);
}

}
}
Loading

0 comments on commit 1978965

Please sign in to comment.