Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
40 commits
Select commit Hold shift + click to select a range
36706d0
Feat: 회원가입 시 비밀번호 유효성 검증 기능 추가
dungbik Jul 5, 2025
f04637f
Style: 코드 컨벤션에 맞지 않는 부분 수정
dungbik Jul 5, 2025
bbf72a6
Test: 비밀번호 유효성 검증기 단위 테스트
dungbik Jul 5, 2025
a56a9a1
Feat: 휴대전화 번호 하이픈(-) 제거 후 저장되도록 수정
dungbik Jul 5, 2025
3fac57d
Feat: 회원가입시 휴대전화 번호 유효성 검증 기능 추가
dungbik Jul 5, 2025
f8aab93
Test: 휴대전화 번호 유효성 검증기 단위 테스트
dungbik Jul 5, 2025
2a828a1
Feat: 이메일 인증번호 전송 기능
dungbik Jul 6, 2025
5fcb801
Chore: application.yml 정리
dungbik Jul 6, 2025
0c0ac3a
Chore: local용 docker-compose.yml 추가
dungbik Jul 6, 2025
d1d36a8
Feat: 이메일 인증번호 제출 기능
dungbik Jul 6, 2025
2adf537
Fix: 검증 커스텀 어노테이션에 파라미터 누락 수정
dungbik Jul 6, 2025
54a0cce
Feat: 회원가입시 인증된 이메일인지 검증하는 로직 추가
dungbik Jul 6, 2025
567bb86
Chore: 잘못된 패키지 위치 정상화
dungbik Jul 6, 2025
1981202
Feat: 휴대전화 번호 유효성 검증기 null 허용하도록 수정
dungbik Jul 6, 2025
62a3fc4
Test: 회원가입 단위 테스트 작성
dungbik Jul 6, 2025
e8f4654
Test: 휴대전화 번호 유효성 검증기 단위 테스트 수정
dungbik Jul 6, 2025
5d64418
Feat: 휴대전화 번호 null 도 가능하도록 수정
dungbik Jul 6, 2025
a4cb73b
Test: 휴대전화 번호 null일 때 회원가입 성공 테스트 케이스 추가
dungbik Jul 6, 2025
dec163c
Fix: 전화번호 중복 체크와 저장되는 값을 일관성 있게 처리
dungbik Jul 6, 2025
de5a4ad
Feat: 이메일 전송 재시도 로직에 재시도 횟수 명시적 설정 추가
dungbik Jul 6, 2025
61f1fed
Feat: 스레드 풀 설정에 유효성 검증(@Min) 추가
dungbik Jul 6, 2025
65d4d39
Feat: 레디스 키 생성시 args 유효성 검증 추가
dungbik Jul 6, 2025
8a47f7c
Feat: Resend 설정에 유효성 검증(@NotEmpty) 추가
dungbik Jul 6, 2025
4a533d0
Chore: JWT 시크릿 기본 값 삭제
dungbik Jul 6, 2025
24a6aa0
Feat: 스레드 풀, Resend 설정 유효성 검증 수정
dungbik Jul 6, 2025
51a9f8a
Chore: application-test.yml 에 jwt secret 추가
dungbik Jul 6, 2025
68b6db4
Feat: 회원가입 하나의 트랜잭션으로 묶음
dungbik Jul 6, 2025
9979018
Feat: 이메일 송신자와 템플릿 수정
dungbik Jul 6, 2025
b71ba66
Feat&Refactor: 이메일 인증번호 전송 메서드 라팩토링 및 이메일 존재 여부 체크 로직 추가
dungbik Jul 6, 2025
461b512
Refactor: 인증번호 발급 여부 체크 중복 코드 제거
dungbik Jul 6, 2025
4d2eeba
Refactor: AuthService 검증 로직 메서드로 추출
dungbik Jul 6, 2025
19fc7fe
Fix: 이메일 인증번호 전송 실패시 로그 기록에서 인증번호 제거
dungbik Jul 6, 2025
d335090
Test: 이메일 인증번호 전송 단위 테스트 작성
dungbik Jul 6, 2025
e17e93d
refactor: 회원가입 단위 테스트 메서드명 간결하게 수정
dungbik Jul 6, 2025
950a9fd
Test: 이메일 인증번호 전송 단위 테스트에서 인증번호 생성 로직 간접 테스트
dungbik Jul 6, 2025
db82650
Test: 이메일 인증번호 확인 단위 테스트 작성
dungbik Jul 6, 2025
5503f7b
Refactor: UserRegisterDto 요청, 응답 별로 분리
dungbik Jul 7, 2025
9caf7d6
Refactor: 이메일 인증관련 DTO 요청, 응답 별로 분리
dungbik Jul 7, 2025
98b9df0
Feat: 휴대전화 암호화 적용
dungbik Jul 11, 2025
cf3a53e
Feat: AES 키 검증 로직
dungbik Jul 11, 2025
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
7 changes: 6 additions & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -28,9 +28,14 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter-security'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'org.springframework.boot:spring-boot-starter-actuator'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-aop'
implementation 'org.springframework.retry:spring-retry'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.9'
implementation 'io.jsonwebtoken:jjwt:0.12.6'
implementation 'com.resend:resend-java:4.1.1'
compileOnly 'org.projectlombok:lombok'
runtimeOnly 'com.mysql:mysql-connector-j'
annotationProcessor 'org.projectlombok:lombok'
Expand Down
17 changes: 17 additions & 0 deletions docker-compose.local.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
services:
flipnote-mysql:
image: mysql:latest
container_name: flipnote-mysql
ports:
- "3306:3306"
environment:
MYSQL_USERNAME: root
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: flipnote
TZ: Asia/Seoul

flipnote-redis:
image: redis:latest
container_name: flipnote-redis
ports:
- "6379:6379"
15 changes: 15 additions & 0 deletions src/main/java/project/flipnote/auth/constants/AuthRedisKey.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package project.flipnote.auth.constants;

import lombok.AllArgsConstructor;
import lombok.Getter;
import project.flipnote.common.constants.RedisKeys;

@Getter
@AllArgsConstructor
public enum AuthRedisKey implements RedisKeys {
EMAIL_CODE("auth:email:code:%s", VerificationConstants.CODE_TTL_MINUTES * 60),
EMAIL_VERIFIED("auth:email:verified:%s", 600);

private final String pattern;
private final int ttlSeconds;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package project.flipnote.auth.constants;

import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public final class VerificationConstants {
public static final int CODE_LENGTH = 6;
public static final int CODE_TTL_MINUTES = 5;
}
18 changes: 18 additions & 0 deletions src/main/java/project/flipnote/auth/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import project.flipnote.auth.model.EmailVerificationConfirmRequest;
import project.flipnote.auth.model.EmailVerificationRequest;
import project.flipnote.auth.model.TokenPair;
import project.flipnote.auth.model.UserLoginDto;
import project.flipnote.auth.service.AuthService;
Expand Down Expand Up @@ -40,4 +42,20 @@ public ResponseEntity<Void> logout(HttpServletResponse servletResponse) {

return ResponseEntity.ok().build();
}

@PostMapping("/email")
public ResponseEntity<Void> sendEmailVerificationCode(@Valid @RequestBody EmailVerificationRequest req) {
authService.sendEmailVerificationCode(req);

return ResponseEntity.ok().build();
}

@PostMapping("/email/confirm")
public ResponseEntity<Void> confirmEmailVerificationCode(
@Valid @RequestBody EmailVerificationConfirmRequest req
) {
authService.confirmEmailVerificationCode(req);

return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package project.flipnote.auth.event;

public record EmailVerificationSendEvent(
String to,
String code
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,12 @@
@RequiredArgsConstructor
public enum AuthErrorCode implements ErrorCode {

INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH_001", "이메일 또는 비밀번호가 올바르지 않습니다.");
INVALID_CREDENTIALS(HttpStatus.UNAUTHORIZED, "AUTH_001", "이메일 또는 비밀번호가 올바르지 않습니다."),
ALREADY_ISSUED_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "AUTH_002", "이미 발급된 인증번호가 있습니다. 잠시 후 다시 시도해 주세요."),
NOT_ISSUED_VERIFICATION_CODE(HttpStatus.BAD_REQUEST, "AUTH_003", "발급된 인증번호가 없습니다. 인증번호를 먼저 요청해 주세요."),
INVALID_VERIFICATION_CODE(HttpStatus.FORBIDDEN, "AUTH_004", "잘못된 인증번호입니다. 입력한 인증번호를 확인해 주세요."),
UNVERIFIED_EMAIL(HttpStatus.FORBIDDEN, "AUTH_005", "인증되지 않은 이메일입니다. 이메일 인증을 완료해 주세요."),
EXISTING_EMAIL(HttpStatus.CONFLICT, "AUTH_006", "이미 가입된 이메일입니다. 다른 이메일을 사용해 주세요.");

private final HttpStatus httpStatus;
private final String code;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package project.flipnote.auth.listener;

import org.springframework.context.event.EventListener;
import org.springframework.retry.annotation.Backoff;
import org.springframework.retry.annotation.Recover;
import org.springframework.retry.annotation.Retryable;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import project.flipnote.auth.constants.VerificationConstants;
import project.flipnote.auth.event.EmailVerificationSendEvent;
import project.flipnote.common.exception.EmailSendException;
import project.flipnote.infra.email.EmailService;

@Slf4j
@RequiredArgsConstructor
@Component
public class EmailVerificationEventListener {

private final EmailService emailService;

@Async
@Retryable(
maxAttempts = 3,
retryFor = { EmailSendException.class },
backoff = @Backoff(delay = 2000, multiplier = 2)
)
@EventListener
public void handleEmailVerificationSendEvent(EmailVerificationSendEvent event) {
emailService.sendEmailVerificationCode(event.to(), event.code(), VerificationConstants.CODE_TTL_MINUTES);
}

@Recover
public void recover(EmailSendException ex, EmailVerificationSendEvent event) {
log.error("이메일 인증번호 전송 3회 실패: to={}", event.to(), ex);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package project.flipnote.auth.model;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import project.flipnote.auth.constants.VerificationConstants;

public record EmailVerificationConfirmRequest(

@Email @NotBlank
String email,

@NotBlank
@Size(min = VerificationConstants.CODE_LENGTH, max = VerificationConstants.CODE_LENGTH)
String code
) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package project.flipnote.auth.model;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;

public record EmailVerificationRequest(

@Email @NotBlank
String email
) {
}
6 changes: 3 additions & 3 deletions src/main/java/project/flipnote/auth/model/UserLoginDto.java
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
package project.flipnote.auth.model;

import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotEmpty;
import jakarta.validation.constraints.NotBlank;

public class UserLoginDto {

public record Request(

@Email @NotEmpty
@Email @NotBlank
String email,

@NotEmpty
@NotBlank
String password
) {
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
package project.flipnote.auth.repository;

import java.time.Duration;
import java.util.Optional;

import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Repository;

import lombok.RequiredArgsConstructor;
import project.flipnote.auth.constants.AuthRedisKey;

@RequiredArgsConstructor
@Repository
public class EmailVerificationRedisRepository {

private final RedisTemplate<String, String> emailRedisTemplate;

public void saveCode(String email, String code) {
String key = AuthRedisKey.EMAIL_CODE.key(email);
Duration ttl = AuthRedisKey.EMAIL_CODE.getTtl();

emailRedisTemplate.opsForValue().set(key, code, ttl);
}

public boolean existCode(String email) {
String key = AuthRedisKey.EMAIL_CODE.key(email);

return emailRedisTemplate.hasKey(key);
}

public Optional<String> findCode(String email) {
String key = AuthRedisKey.EMAIL_CODE.key(email);

String code = emailRedisTemplate.opsForValue().get(key);

return Optional.ofNullable(code);
}

public void deleteCode(String email) {
String key = AuthRedisKey.EMAIL_CODE.key(email);

emailRedisTemplate.delete(key);
}

public void markAsVerified(String email) {
String key = AuthRedisKey.EMAIL_VERIFIED.key(email);
Duration ttl = AuthRedisKey.EMAIL_VERIFIED.getTtl();

emailRedisTemplate.opsForValue().set(key, "1", ttl);
}

public boolean isVerified(String email) {
String key = AuthRedisKey.EMAIL_VERIFIED.key(email);

return emailRedisTemplate.hasKey(key);
}

public void deleteVerified(String email) {
String key = AuthRedisKey.EMAIL_VERIFIED.key(email);

emailRedisTemplate.delete(key);
}
}
89 changes: 85 additions & 4 deletions src/main/java/project/flipnote/auth/service/AuthService.java
Original file line number Diff line number Diff line change
@@ -1,33 +1,45 @@
package project.flipnote.auth.service;

import java.util.UUID;
import java.security.SecureRandom;
import java.util.Objects;

import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import project.flipnote.auth.constants.VerificationConstants;
import project.flipnote.auth.event.EmailVerificationSendEvent;
import project.flipnote.auth.exception.AuthErrorCode;
import project.flipnote.auth.model.EmailVerificationConfirmRequest;
import project.flipnote.auth.model.EmailVerificationRequest;
import project.flipnote.auth.model.TokenPair;
import project.flipnote.auth.model.UserLoginDto;
import project.flipnote.auth.repository.EmailVerificationRedisRepository;
import project.flipnote.common.exception.BizException;
import project.flipnote.common.security.jwt.JwtComponent;
import project.flipnote.user.entity.User;
import project.flipnote.user.repository.UserRepository;

@Slf4j
@RequiredArgsConstructor
@Service
public class AuthService {

private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtComponent jwtComponent;
private final EmailVerificationRedisRepository emailVerificationRedisRepository;
private final ApplicationEventPublisher eventPublisher;

private static final SecureRandom random = new SecureRandom();

public TokenPair login(UserLoginDto.Request req) {
User user = findByEmailOrThrow(req);

if (!passwordEncoder.matches(req.password(), user.getPassword())) {
throw new BizException(AuthErrorCode.INVALID_CREDENTIALS);
}
validatePasswordMatch(req.password(), user.getPassword());
log.error("{}", user.getPhone());

return jwtComponent.generateTokenPair(user.getEmail(), user.getId(), user.getRole().name());
}
Expand All @@ -36,4 +48,73 @@ private User findByEmailOrThrow(UserLoginDto.Request req) {
return userRepository.findByEmail(req.email())
.orElseThrow(() -> new BizException(AuthErrorCode.INVALID_CREDENTIALS));
}

private void validatePasswordMatch(String rawPassword, String encodedPassword) {
if (!passwordEncoder.matches(rawPassword, encodedPassword)) {
throw new BizException(AuthErrorCode.INVALID_CREDENTIALS);
}
}

public void sendEmailVerificationCode(EmailVerificationRequest req) {
final String email = req.email();

validateEmailIsAvailable(email);
validateVerificationCodeNotExists(email);

final String code = generateVerificationCode(VerificationConstants.CODE_LENGTH);

emailVerificationRedisRepository.saveCode(email, code);

eventPublisher.publishEvent(new EmailVerificationSendEvent(email, code));
}

private void validateEmailIsAvailable(String email) {
if (userRepository.existsByEmail(email)) {
throw new BizException(AuthErrorCode.EXISTING_EMAIL);
}
}

private void validateVerificationCodeNotExists(String email) {
if (emailVerificationRedisRepository.existCode(email)) {
throw new BizException(AuthErrorCode.ALREADY_ISSUED_VERIFICATION_CODE);
}
}

public void confirmEmailVerificationCode(EmailVerificationConfirmRequest req) {
String email = req.email();

String code = findVerificationCodeOrThrow(email);

validateVerificationCode(req.code(), code);

emailVerificationRedisRepository.deleteCode(email);
emailVerificationRedisRepository.markAsVerified(email);
}

private String findVerificationCodeOrThrow(String email) {
return emailVerificationRedisRepository.findCode(email)
.orElseThrow(() -> new BizException(AuthErrorCode.NOT_ISSUED_VERIFICATION_CODE));
}

private void validateVerificationCode(String inputCode, String savedCode) {
if (!Objects.equals(inputCode, savedCode)) {
throw new BizException(AuthErrorCode.INVALID_VERIFICATION_CODE);
}
}

public void validateEmail(String email) {
if (!emailVerificationRedisRepository.isVerified(email)) {
throw new BizException(AuthErrorCode.UNVERIFIED_EMAIL);
}
}

public void deleteVerifiedEmail(String email) {
emailVerificationRedisRepository.deleteVerified(email);
}

private String generateVerificationCode(int length) {
int origin = (int)Math.pow(10, length - 1);
int bound = (int)Math.pow(10, length);
return String.valueOf(random.nextInt(origin, bound));
}
}
Loading
Loading