diff --git a/.env.example b/.env.example index bcef623..5e398be 100644 --- a/.env.example +++ b/.env.example @@ -8,4 +8,8 @@ DB_USER=string DB_PASSWORD=string REDIS_HOST=string REDIS_PORT=number -REDIS_PASSWORD=string \ No newline at end of file +REDIS_PASSWORD=string +EMAIL_HOST=string +EMAIL_PORT=number +EMAIL_USERNAME=string +EMAIL_PASSWORD=string \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/port/AuthenticationPersistencePort.java b/src/main/java/com/ampersand/groom/domain/auth/application/port/AuthenticationPersistencePort.java new file mode 100644 index 0000000..98c23da --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/application/port/AuthenticationPersistencePort.java @@ -0,0 +1,12 @@ +package com.ampersand.groom.domain.auth.application.port; + +import com.ampersand.groom.domain.auth.domain.Authentication; + +public interface AuthenticationPersistencePort { + + Boolean existsAuthenticationByEmail(String email); + + Authentication findAuthenticationByEmail(String email); + + void saveAuthentication(Authentication authentication); +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/port/EmailVerificationPort.java b/src/main/java/com/ampersand/groom/domain/auth/application/port/EmailVerificationPort.java index a21058a..6d5f63b 100644 --- a/src/main/java/com/ampersand/groom/domain/auth/application/port/EmailVerificationPort.java +++ b/src/main/java/com/ampersand/groom/domain/auth/application/port/EmailVerificationPort.java @@ -1,21 +1,18 @@ package com.ampersand.groom.domain.auth.application.port; -import com.ampersand.groom.domain.auth.persistence.EmailVerification; - -import java.time.LocalDateTime; -import java.util.Optional; +import com.ampersand.groom.domain.auth.domain.AuthCode; public interface EmailVerificationPort { - // 인증 정보 저장 - EmailVerification save(EmailVerification emailVerification); + // 코드로 인증 코드 존재 여부 조회 + Boolean existsAuthCodeByCode(String code); - // 인증 코드로 이메일 조회 - Optional findByCode(String code); + // 코드로 인증 코드 조회 + AuthCode findAuthCodeByCode(String code); - // 이메일로 인증 정보 조회 - Optional findByEmail(String email); + // 인증 코드 저장 + void saveAuthCode(AuthCode authCode); - // 만료된 인증 정보 삭제 - void deleteAllExpired(LocalDateTime now); + // 인증 코드 삭제 + void deleteAuthCodeByCode(String code); } \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/service/AuthService.java b/src/main/java/com/ampersand/groom/domain/auth/application/service/AuthService.java index 6e54dcb..2253505 100644 --- a/src/main/java/com/ampersand/groom/domain/auth/application/service/AuthService.java +++ b/src/main/java/com/ampersand/groom/domain/auth/application/service/AuthService.java @@ -1,6 +1,7 @@ package com.ampersand.groom.domain.auth.application.service; import com.ampersand.groom.domain.auth.application.port.AuthPort; +import com.ampersand.groom.domain.auth.application.port.AuthenticationPersistencePort; import com.ampersand.groom.domain.auth.domain.JwtToken; import com.ampersand.groom.domain.auth.exception.*; import com.ampersand.groom.domain.auth.presentation.data.request.SignupRequest; @@ -22,6 +23,7 @@ public class AuthService { private final JwtService jwtService; private final PasswordEncoder passwordEncoder; private final AuthPort authPort; + private final AuthenticationPersistencePort authenticationPersistencePort; @Value("${spring.jwt.token.access-expiration}") private long accessTokenExpiration; @@ -46,6 +48,10 @@ public JwtToken refreshToken(String refreshToken) { public void signup(SignupRequest request) { checkUserExists(request.getEmail()); + if(!authenticationPersistencePort.existsAuthenticationByEmail(request.getEmail()) + || !authenticationPersistencePort.findAuthenticationByEmail(request.getEmail()).getVerified()) { + throw new UserForbiddenException(); + } MemberJpaEntity newUser = createNewUser(request, calculateGenerationFromEmail(request.getEmail())); authPort.save(newUser); } diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/service/EmailSchedulingService.java b/src/main/java/com/ampersand/groom/domain/auth/application/service/EmailSchedulingService.java deleted file mode 100644 index ceba45a..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/application/service/EmailSchedulingService.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.ampersand.groom.domain.auth.application.service; - -import com.ampersand.groom.domain.auth.application.port.EmailVerificationPort; -import jakarta.transaction.Transactional; -import lombok.RequiredArgsConstructor; -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Service; - -import java.time.LocalDateTime; - -@Service -@RequiredArgsConstructor -public class EmailSchedulingService { - - private final EmailVerificationPort emailVerificationPort; - - // 만료된 인증 정보 삭제(1시간) - @Scheduled(fixedRate = 3600000) - @Transactional - public void deleteExpiredVerifications() { - emailVerificationPort.deleteAllExpired(LocalDateTime.now()); - } -} diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/service/EmailVerificationService.java b/src/main/java/com/ampersand/groom/domain/auth/application/service/EmailVerificationService.java index e8d795d..14141db 100644 --- a/src/main/java/com/ampersand/groom/domain/auth/application/service/EmailVerificationService.java +++ b/src/main/java/com/ampersand/groom/domain/auth/application/service/EmailVerificationService.java @@ -1,36 +1,42 @@ package com.ampersand.groom.domain.auth.application.service; +import com.ampersand.groom.domain.auth.application.port.AuthenticationPersistencePort; import com.ampersand.groom.domain.auth.application.port.EmailVerificationPort; +import com.ampersand.groom.domain.auth.domain.AuthCode; +import com.ampersand.groom.domain.auth.domain.Authentication; +import com.ampersand.groom.domain.auth.exception.EmailAuthRateLimitException; import com.ampersand.groom.domain.auth.exception.EmailFormatInvalidException; import com.ampersand.groom.domain.auth.exception.VerificationCodeFormatInvalidException; import com.ampersand.groom.domain.auth.exception.VerificationCodeExpiredOrInvalidException; -import com.ampersand.groom.domain.auth.persistence.EmailVerification; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.mail.SimpleMailMessage; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.stereotype.Service; import java.util.Random; +@Slf4j @Service @RequiredArgsConstructor public class EmailVerificationService { + private final AuthenticationPersistencePort authenticationPersistencePort; private final EmailVerificationPort emailVerificationPort; private final JavaMailSender javaMailSender; private static final int MAX_EMAIL_LENGTH = 16; private static final int CODE_LENGTH = 8; + private static final int MAX_ATTEMPT_COUNT = 5; + private static final long TTL = 300L; - - //8자리 숫자 인증 코드 생성 + // 인증 코드 생성 private String generateVerificationCode() { - Random random = new Random(); - int code = 10000000 + random.nextInt(90000000); + int code = 10000000 + new Random().nextInt(90000000); return String.valueOf(code); } - // 이메일 전송 메서드 + // 이메일 전송 private void sendEmail(String to, String subject, String text) { SimpleMailMessage message = new SimpleMailMessage(); message.setTo(to); @@ -39,47 +45,101 @@ private void sendEmail(String to, String subject, String text) { javaMailSender.send(message); } - // 회원가입 인증 이메일 전송 - public void sendSignupVerificationEmail(String email) { - verifyEmail(email); + // 인증 이메일 전송 (회원가입 / 비밀번호 재설정 공통 처리) + private void sendVerificationEmail(String email, String subject) { + validateEmailFormat(email); + checkAttemptCount(email); + increaseAttemptCount(email); + String code = generateVerificationCode(); - sendEmail(email, "회원가입 인증", "귀하의 인증 코드는: " + code); + sendEmail(email, subject, "귀하의 인증 코드는: " + code); - EmailVerification emailVerification = new EmailVerification(email, code); - - emailVerificationPort.save(emailVerification); + AuthCode authCode = AuthCode.builder() + .email(email) + .code(code) + .ttl(TTL) + .build(); + + emailVerificationPort.saveAuthCode(authCode); } - // 비밀번호 변경을 위한 인증 이메일 전송 - public void sendPasswordResetEmail(String email) { - verifyEmail(email); - String code = generateVerificationCode(); - sendEmail(email, "비밀번호 변경 인증", "귀하의 인증 코드는: " + code); + // 회원가입용 인증 메일 전송 + public void sendSignupVerificationEmail(String email) { + sendVerificationEmail(email, "회원가입 인증"); + } - EmailVerification emailVerification = new EmailVerification(email, code); - - emailVerificationPort.save(emailVerification); + // 비밀번호 재설정 인증 메일 전송 + public void sendPasswordResetEmail(String email) { + sendVerificationEmail(email, "비밀번호 변경 인증"); } // 인증 코드 검증 public void verifyCode(String code) { - if(code == null || code.length() != CODE_LENGTH) { + if (code == null || code.length() != CODE_LENGTH) { throw new VerificationCodeFormatInvalidException(); } - EmailVerification emailVerification = emailVerificationPort.findByCode(code) - .orElseThrow(VerificationCodeExpiredOrInvalidException::new); - + if (!emailVerificationPort.existsAuthCodeByCode(code)) { + throw new VerificationCodeExpiredOrInvalidException(); + } - emailVerification.setIsVerified(true); - emailVerificationPort.save(emailVerification); + AuthCode authCode = emailVerificationPort.findAuthCodeByCode(code); + emailVerificationPort.deleteAuthCodeByCode(code); + markEmailVerified(authCode.getEmail()); } - // 이메일 검증 - public void verifyEmail(String email) { - if(email == null || email.length() != MAX_EMAIL_LENGTH) { + // 이메일 형식 검증 + private void validateEmailFormat(String email) { + if (email == null || email.length() != MAX_EMAIL_LENGTH) { throw new EmailFormatInvalidException(); } + } + + // 인증 시도 횟수 초과 체크 + private void checkAttemptCount(String email) { + if (authenticationPersistencePort.existsAuthenticationByEmail(email)) { + int attempts = authenticationPersistencePort.findAuthenticationByEmail(email).getAttemptCount(); + if (attempts >= MAX_ATTEMPT_COUNT) { + throw new EmailAuthRateLimitException(); + } + } + } + + // 인증 시도 횟수 증가 + private void increaseAttemptCount(String email) { + Authentication existingAuth = authenticationPersistencePort.existsAuthenticationByEmail(email) + ? authenticationPersistencePort.findAuthenticationByEmail(email) + : null; + Authentication updatedAuth; + if(existingAuth != null) { + updatedAuth = Authentication.builder() + .email(email) + .attemptCount(existingAuth.getAttemptCount() + 1) + .verified(existingAuth.getVerified()) + .ttl(existingAuth.getTtl()) + .build(); + } else { + updatedAuth = Authentication.builder() + .email(email) + .attemptCount(1) + .verified(false) + .ttl(TTL) + .build(); + } + authenticationPersistencePort.saveAuthentication(updatedAuth); + } + + // 인증 완료 처리 + private void markEmailVerified(String email) { + Authentication authentication = authenticationPersistencePort.findAuthenticationByEmail(email); + + Authentication updatedAuth = Authentication.builder() + .email(email) + .attemptCount(authentication.getAttemptCount()) + .verified(true) + .ttl(authentication.getTtl()) + .build(); + authenticationPersistencePort.saveAuthentication(updatedAuth); } -} +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/domain/AuthCode.java b/src/main/java/com/ampersand/groom/domain/auth/domain/AuthCode.java new file mode 100644 index 0000000..af285e3 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/domain/AuthCode.java @@ -0,0 +1,12 @@ +package com.ampersand.groom.domain.auth.domain; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class AuthCode { + private final String email; + private final String code; + private final Long ttl; +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/domain/Authentication.java b/src/main/java/com/ampersand/groom/domain/auth/domain/Authentication.java new file mode 100644 index 0000000..f196277 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/domain/Authentication.java @@ -0,0 +1,13 @@ +package com.ampersand.groom.domain.auth.domain; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class Authentication { + private String email; + private int attemptCount; + private Boolean verified; + private Long ttl; +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/exception/AuthenticationNotFoundException.java b/src/main/java/com/ampersand/groom/domain/auth/exception/AuthenticationNotFoundException.java new file mode 100644 index 0000000..27c4c4e --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/exception/AuthenticationNotFoundException.java @@ -0,0 +1,10 @@ +package com.ampersand.groom.domain.auth.exception; + +import com.ampersand.groom.global.error.ErrorCode; +import com.ampersand.groom.global.error.exception.GroomException; + +public class AuthenticationNotFoundException extends GroomException { + public AuthenticationNotFoundException() { + super(ErrorCode.AUTHENTICATION_NOT_FOUND); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/exception/EmailAuthRateLimitException.java b/src/main/java/com/ampersand/groom/domain/auth/exception/EmailAuthRateLimitException.java new file mode 100644 index 0000000..5cd1a8f --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/exception/EmailAuthRateLimitException.java @@ -0,0 +1,10 @@ +package com.ampersand.groom.domain.auth.exception; + +import com.ampersand.groom.global.error.ErrorCode; +import com.ampersand.groom.global.error.exception.GroomException; + +public class EmailAuthRateLimitException extends GroomException { + public EmailAuthRateLimitException() { + super(ErrorCode.EMAIL_AUTHENTICATION_TOO_MANY_REQUESTS); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/email/AuthenticationPersistenceAdapter.java b/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/email/AuthenticationPersistenceAdapter.java new file mode 100644 index 0000000..945ffe3 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/email/AuthenticationPersistenceAdapter.java @@ -0,0 +1,33 @@ +package com.ampersand.groom.domain.auth.persistence.adapter.email; + +import com.ampersand.groom.domain.auth.application.port.AuthenticationPersistencePort; +import com.ampersand.groom.domain.auth.domain.Authentication; +import com.ampersand.groom.domain.auth.exception.AuthenticationNotFoundException; +import com.ampersand.groom.domain.auth.persistence.mapper.AuthenticationMapper; +import com.ampersand.groom.domain.auth.persistence.repository.AuthenticationRedisRepository; +import com.ampersand.groom.global.annotation.adapter.Adapter; +import com.ampersand.groom.global.annotation.adapter.constant.AdapterType; +import lombok.RequiredArgsConstructor; + +@Adapter(AdapterType.OUTBOUND) +@RequiredArgsConstructor +public class AuthenticationPersistenceAdapter implements AuthenticationPersistencePort { + + private final AuthenticationRedisRepository authenticationRedisRepository; + private final AuthenticationMapper authenticationMapper; + + @Override + public Boolean existsAuthenticationByEmail(String email) { + return authenticationRedisRepository.existsById(email); + } + + @Override + public Authentication findAuthenticationByEmail(String email) { + return authenticationMapper.toDomain(authenticationRedisRepository.findById(email).orElseThrow(AuthenticationNotFoundException::new)); + } + + @Override + public void saveAuthentication(Authentication authentication) { + authenticationRedisRepository.save(authenticationMapper.toEntity(authentication)); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/email/EmailVerificationAdapter.java b/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/email/EmailVerificationAdapter.java index 47a7376..abec489 100644 --- a/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/email/EmailVerificationAdapter.java +++ b/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/email/EmailVerificationAdapter.java @@ -1,38 +1,38 @@ package com.ampersand.groom.domain.auth.persistence.adapter.email; import com.ampersand.groom.domain.auth.application.port.EmailVerificationPort; - -import com.ampersand.groom.domain.auth.persistence.EmailVerification; -import com.ampersand.groom.domain.auth.persistence.repository.JpaEmailVerificationRepository; +import com.ampersand.groom.domain.auth.domain.AuthCode; +import com.ampersand.groom.domain.auth.persistence.mapper.AuthCodeMapper; +import com.ampersand.groom.domain.auth.persistence.repository.AuthCodeRedisRepository; +import com.ampersand.groom.global.annotation.adapter.Adapter; +import com.ampersand.groom.global.annotation.adapter.constant.AdapterType; import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.time.LocalDateTime; -import java.util.Optional; -@Component +@Adapter(AdapterType.OUTBOUND) @RequiredArgsConstructor public class EmailVerificationAdapter implements EmailVerificationPort { - private final JpaEmailVerificationRepository jpaEmailVerificationRepository; + // private final JpaEmailVerificationRepository jpaEmailVerificationRepository; + private final AuthCodeRedisRepository authCodeRedisRepository; + private final AuthCodeMapper authCodeMapper; @Override - public EmailVerification save(EmailVerification emailVerification) { - return jpaEmailVerificationRepository.save(emailVerification); + public void saveAuthCode(AuthCode authCode) { + authCodeRedisRepository.save(authCodeMapper.toEntity(authCode)); } @Override - public Optional findByCode(String code) { - return jpaEmailVerificationRepository.findByCode(code); + public Boolean existsAuthCodeByCode(String code) { + return authCodeRedisRepository.existsByCode(code); } @Override - public Optional findByEmail(String email) { - return jpaEmailVerificationRepository.findByEmail(email); + public AuthCode findAuthCodeByCode(String code) { + return authCodeMapper.toDomain(authCodeRedisRepository.findByCode(code)); } @Override - public void deleteAllExpired(LocalDateTime now) { - jpaEmailVerificationRepository.deleteAllExpired(now); + public void deleteAuthCodeByCode(String code) { + authCodeRedisRepository.deleteByCode(code); } -} +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/persistence/entity/AuthCodeRedisEntity.java b/src/main/java/com/ampersand/groom/domain/auth/persistence/entity/AuthCodeRedisEntity.java new file mode 100644 index 0000000..7b6b946 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/persistence/entity/AuthCodeRedisEntity.java @@ -0,0 +1,30 @@ +package com.ampersand.groom.domain.auth.persistence.entity; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; +import org.springframework.data.redis.core.index.Indexed; + +import java.util.concurrent.TimeUnit; + +@RedisHash(value = "auth_code") +@Getter +@NoArgsConstructor +public class AuthCodeRedisEntity { + @Id + private String email; + @Indexed + private String code; + @TimeToLive(unit = TimeUnit.SECONDS) + private Long ttl; + + @Builder + public AuthCodeRedisEntity(String email, String code, Long ttl) { + this.email = email; + this.code = code; + this.ttl = ttl; + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/persistence/entity/AuthenticationRedisEntity.java b/src/main/java/com/ampersand/groom/domain/auth/persistence/entity/AuthenticationRedisEntity.java new file mode 100644 index 0000000..efcf658 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/persistence/entity/AuthenticationRedisEntity.java @@ -0,0 +1,30 @@ +package com.ampersand.groom.domain.auth.persistence.entity; + +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import java.util.concurrent.TimeUnit; + +@RedisHash("authentication") +@Getter +@NoArgsConstructor +public class AuthenticationRedisEntity { + @Id + private String email; + private int attemptCount; + private Boolean verified; + @TimeToLive(unit = TimeUnit.SECONDS) + private Long ttl; + + @Builder + public AuthenticationRedisEntity(String email, int attemptCount, Boolean verified, Long ttl) { + this.email = email; + this.attemptCount = attemptCount; + this.verified = verified; + this.ttl = ttl; + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/persistence/mapper/AuthCodeMapper.java b/src/main/java/com/ampersand/groom/domain/auth/persistence/mapper/AuthCodeMapper.java new file mode 100644 index 0000000..9f5716a --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/persistence/mapper/AuthCodeMapper.java @@ -0,0 +1,28 @@ +package com.ampersand.groom.domain.auth.persistence.mapper; + +import com.ampersand.groom.domain.auth.domain.AuthCode; +import com.ampersand.groom.domain.auth.persistence.entity.AuthCodeRedisEntity; +import com.ampersand.groom.global.mapper.GenericMapper; +import org.springframework.stereotype.Component; + +@Component +public class AuthCodeMapper implements GenericMapper { + + @Override + public AuthCode toDomain(AuthCodeRedisEntity authCodeRedisEntity) { + return AuthCode.builder() + .email(authCodeRedisEntity.getEmail()) + .code(authCodeRedisEntity.getCode()) + .ttl(authCodeRedisEntity.getTtl()) + .build(); + } + + @Override + public AuthCodeRedisEntity toEntity(AuthCode authCode) { + return AuthCodeRedisEntity.builder() + .email(authCode.getEmail()) + .code(authCode.getCode()) + .ttl(authCode.getTtl()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/persistence/mapper/AuthenticationMapper.java b/src/main/java/com/ampersand/groom/domain/auth/persistence/mapper/AuthenticationMapper.java new file mode 100644 index 0000000..50f514b --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/persistence/mapper/AuthenticationMapper.java @@ -0,0 +1,30 @@ +package com.ampersand.groom.domain.auth.persistence.mapper; + +import com.ampersand.groom.domain.auth.domain.Authentication; +import com.ampersand.groom.domain.auth.persistence.entity.AuthenticationRedisEntity; +import com.ampersand.groom.global.mapper.GenericMapper; +import org.springframework.stereotype.Component; + +@Component +public class AuthenticationMapper implements GenericMapper { + + @Override + public Authentication toDomain(AuthenticationRedisEntity authenticationRedisEntity) { + return Authentication.builder() + .email(authenticationRedisEntity.getEmail()) + .attemptCount(authenticationRedisEntity.getAttemptCount()) + .verified(authenticationRedisEntity.getVerified()) + .ttl(authenticationRedisEntity.getTtl()) + .build(); + } + + @Override + public AuthenticationRedisEntity toEntity(Authentication authentication) { + return AuthenticationRedisEntity.builder() + .email(authentication.getEmail()) + .attemptCount(authentication.getAttemptCount()) + .verified(authentication.getVerified()) + .ttl(authentication.getTtl()) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/persistence/repository/AuthCodeRedisRepository.java b/src/main/java/com/ampersand/groom/domain/auth/persistence/repository/AuthCodeRedisRepository.java new file mode 100644 index 0000000..ca5284b --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/persistence/repository/AuthCodeRedisRepository.java @@ -0,0 +1,14 @@ +package com.ampersand.groom.domain.auth.persistence.repository; + +import com.ampersand.groom.domain.auth.persistence.entity.AuthCodeRedisEntity; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface AuthCodeRedisRepository extends CrudRepository { + AuthCodeRedisEntity findByCode(String code); + + Boolean existsByCode(String code); + + void deleteByCode(String code); +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/persistence/repository/AuthenticationRedisRepository.java b/src/main/java/com/ampersand/groom/domain/auth/persistence/repository/AuthenticationRedisRepository.java new file mode 100644 index 0000000..3fbdf8b --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/persistence/repository/AuthenticationRedisRepository.java @@ -0,0 +1,10 @@ +package com.ampersand.groom.domain.auth.persistence.repository; + +import com.ampersand.groom.domain.auth.persistence.entity.AuthenticationRedisEntity; +import org.springframework.data.repository.CrudRepository; + +import java.util.Optional; + +public interface AuthenticationRedisRepository extends CrudRepository { + Optional findByEmail(String email); +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/controller/AuthController.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/controller/AuthController.java index 683dc64..d8353bb 100644 --- a/src/main/java/com/ampersand/groom/domain/auth/presentation/controller/AuthController.java +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/controller/AuthController.java @@ -20,8 +20,8 @@ public class AuthController { @PostMapping("/signIn") public ResponseEntity signIn(@RequestBody @Valid SignInRequest request) { - JwtToken jwtToken = authService.signIn(request.getEmail(), request.getPassword()); - return ResponseEntity.ok(jwtToken); + JwtToken jwtToken = authService.signIn(request.getEmail(), request.getPassword()); + return ResponseEntity.ok(jwtToken); } @PostMapping("/signup") @@ -39,18 +39,18 @@ public ResponseEntity refresh(@RequestBody @Valid RefreshRequest request) { @PostMapping("/verify-email") public ResponseEntity verifyEmail(@RequestBody @Valid VerificationCodeRequest request) { emailVerificationUseCase.executeVerifyCode(request.getCode()); - return ResponseEntity.status(HttpStatus.RESET_CONTENT).body("Verification successful."); + return ResponseEntity.status(HttpStatus.NO_CONTENT).body("Verification successful."); } @PostMapping("/signup/email") public ResponseEntity sendSignupVerificationEmail(@RequestBody @Valid EmailRequest request) { emailVerificationUseCase.executeSendSignupVerificationEmail(request.getEmail()); - return ResponseEntity.status(HttpStatus.RESET_CONTENT).body("Verification email sent"); + return ResponseEntity.status(HttpStatus.NO_CONTENT).body("Verification email sent"); } @PostMapping("/password-change/email") public ResponseEntity sendPasswordResetEmail(@RequestBody @Valid EmailRequest request) { emailVerificationUseCase.executeSendPasswordResetEmail(request.getEmail()); - return ResponseEntity.status(HttpStatus.RESET_CONTENT).body("Verification email sent"); + return ResponseEntity.status(HttpStatus.NO_CONTENT).body("Verification email sent"); } } diff --git a/src/main/java/com/ampersand/groom/domain/booking/application/port/BookingPersistencePort.java b/src/main/java/com/ampersand/groom/domain/booking/application/port/BookingPersistencePort.java index 6254136..5d9a6ba 100644 --- a/src/main/java/com/ampersand/groom/domain/booking/application/port/BookingPersistencePort.java +++ b/src/main/java/com/ampersand/groom/domain/booking/application/port/BookingPersistencePort.java @@ -10,6 +10,8 @@ public interface BookingPersistencePort { List findBookingByDateAndTimeAndPlaceWithLock(LocalDate date, String time, String place); + List findBookingByMemberId(Long memberId); + Booking findBookingByIdWithLock(Long bookingId); Boolean existsBookingByDateAndTimeAndPlace(LocalDate date, String time, String place); diff --git a/src/main/java/com/ampersand/groom/domain/booking/persistence/BookingPersistenceAdapter.java b/src/main/java/com/ampersand/groom/domain/booking/persistence/BookingPersistenceAdapter.java index 7a5f575..143efbe 100644 --- a/src/main/java/com/ampersand/groom/domain/booking/persistence/BookingPersistenceAdapter.java +++ b/src/main/java/com/ampersand/groom/domain/booking/persistence/BookingPersistenceAdapter.java @@ -52,6 +52,17 @@ public List findBookingByDateAndTimeAndPlaceWithLock(LocalDate date, St .toList(); } + @Override + public List findBookingByMemberId(Long memberId) { + return queryFactory + .selectFrom(bookingJpaEntity) + .where(bookingJpaEntity.president.id.eq(memberId)) + .fetch() + .stream() + .map(bookingMapper::toDomain) + .toList(); + } + @Override public Booking findBookingByIdWithLock(Long bookingId) { return Optional.ofNullable( diff --git a/src/main/java/com/ampersand/groom/domain/member/application/MemberApplicationAdapter.java b/src/main/java/com/ampersand/groom/domain/member/application/MemberApplicationAdapter.java index 898642c..81869ac 100644 --- a/src/main/java/com/ampersand/groom/domain/member/application/MemberApplicationAdapter.java +++ b/src/main/java/com/ampersand/groom/domain/member/application/MemberApplicationAdapter.java @@ -2,7 +2,9 @@ import com.ampersand.groom.domain.member.application.port.MemberApplicationPort; import com.ampersand.groom.domain.member.application.usecase.FindAllMembersUseCase; +import com.ampersand.groom.domain.member.application.usecase.FindCurrentMemberUseCase; import com.ampersand.groom.domain.member.application.usecase.FindMembersByCriteriaUseCase; +import com.ampersand.groom.domain.member.application.usecase.UpdatePasswordUseCase; import com.ampersand.groom.domain.member.domain.constant.MemberRole; import com.ampersand.groom.domain.member.presentation.data.response.GetCurrentMemberResponse; import com.ampersand.groom.domain.member.presentation.data.response.GetMemberResponse; @@ -18,6 +20,8 @@ public class MemberApplicationAdapter implements MemberApplicationPort { private final FindAllMembersUseCase findAllMembersUseCase; private final FindMembersByCriteriaUseCase findMembersByCriteriaUseCase; + private final FindCurrentMemberUseCase findCurrentMemberUseCase; + private final UpdatePasswordUseCase updatePasswordUseCase; @Override public List findAllMembers() { @@ -31,11 +35,11 @@ public List findMembersByCriteria(Long id, String name, Integ @Override public GetCurrentMemberResponse findCurrentMember() { - return null; // TODO: 인증/인가 및 booking 관련 로직 구현 시 구현 + return findCurrentMemberUseCase.execute(); } @Override public void updatePassword(Long id, String currentPassword, String newPassword) { - // TODO: 인증/인가 및 Email 전송 로직 구현 시 구현 + updatePasswordUseCase.execute(id, currentPassword, newPassword); } } diff --git a/src/main/java/com/ampersand/groom/domain/member/application/usecase/FindCurrentMemberUseCase.java b/src/main/java/com/ampersand/groom/domain/member/application/usecase/FindCurrentMemberUseCase.java new file mode 100644 index 0000000..a1237fa --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/member/application/usecase/FindCurrentMemberUseCase.java @@ -0,0 +1,43 @@ +package com.ampersand.groom.domain.member.application.usecase; + +import com.ampersand.groom.domain.booking.application.port.BookingPersistencePort; +import com.ampersand.groom.domain.member.application.port.MemberPersistencePort; +import com.ampersand.groom.domain.member.domain.Member; +import com.ampersand.groom.domain.member.persistence.mapper.MemberMapper; +import com.ampersand.groom.domain.member.presentation.data.response.GetCurrentMemberResponse; +import com.ampersand.groom.domain.member.presentation.data.response.GetMemberBookingResponse; +import com.ampersand.groom.global.annotation.usecase.UseCaseWithReadOnlyTransaction; +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.util.List; + +@UseCaseWithReadOnlyTransaction +@RequiredArgsConstructor +public class FindCurrentMemberUseCase { + + private final MemberPersistencePort memberPersistencePort; + private final BookingPersistencePort bookingPersistencePort; + private final MemberMapper memberMapper; + + public GetCurrentMemberResponse execute() { + Member currentMember = memberPersistencePort.findMemberByEmail(SecurityContextHolder.getContext().getAuthentication().getPrincipal().toString()); + List bookings = bookingPersistencePort.findBookingByMemberId(currentMember.getId()).stream().map( + booking -> new GetMemberBookingResponse( + booking.getTimeSlot().getPlace().getPlaceName(), + booking.getParticipants().stream().map(memberMapper::toResponse).toList(), + booking.getBookingDate(), + booking.getTimeSlot().getTimeSlotId().timeLabel() + ) + ).toList(); + return new GetCurrentMemberResponse( + currentMember.getId(), + currentMember.getName(), + currentMember.getGeneration(), + currentMember.getEmail(), + currentMember.getIsAvailable(), + currentMember.getRole(), + bookings + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/member/application/usecase/UpdatePasswordUseCase.java b/src/main/java/com/ampersand/groom/domain/member/application/usecase/UpdatePasswordUseCase.java new file mode 100644 index 0000000..3c8f745 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/member/application/usecase/UpdatePasswordUseCase.java @@ -0,0 +1,31 @@ +package com.ampersand.groom.domain.member.application.usecase; + +import com.ampersand.groom.domain.auth.application.port.AuthenticationPersistencePort; +import com.ampersand.groom.domain.auth.exception.PasswordInvalidException; +import com.ampersand.groom.domain.auth.exception.UserForbiddenException; +import com.ampersand.groom.domain.member.application.port.MemberPersistencePort; +import com.ampersand.groom.domain.member.domain.Member; +import com.ampersand.groom.global.annotation.usecase.UseCaseWithTransaction; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@UseCaseWithTransaction +@RequiredArgsConstructor +public class UpdatePasswordUseCase { + + private final AuthenticationPersistencePort authenticationPersistencePort; + private final MemberPersistencePort memberPersistencePort; + private final BCryptPasswordEncoder bCryptPasswordEncoder; + + public void execute(Long id, String currentPassword, String newPassword) { + Member member = memberPersistencePort.findMemberById(id); + if (!authenticationPersistencePort.existsAuthenticationByEmail(member.getEmail()) + || !authenticationPersistencePort.findAuthenticationByEmail(member.getEmail()).getVerified()) { + throw new UserForbiddenException(); + } + if (!bCryptPasswordEncoder.matches(currentPassword, member.getPassword())) { + throw new PasswordInvalidException(); + } + memberPersistencePort.updateMemberPassword(member.getId(), bCryptPasswordEncoder.encode(newPassword)); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/member/persistence/mapper/MemberMapper.java b/src/main/java/com/ampersand/groom/domain/member/persistence/mapper/MemberMapper.java index 10a2f3f..6151f89 100644 --- a/src/main/java/com/ampersand/groom/domain/member/persistence/mapper/MemberMapper.java +++ b/src/main/java/com/ampersand/groom/domain/member/persistence/mapper/MemberMapper.java @@ -44,6 +44,4 @@ public GetMemberResponse toResponse(Member member) { member.getRole() ); } - - // TODO: booking 구현 후 current member api 구현 시 오버로딩 하여 toResponse 메서드 하나 더 구현해야 할것 같습니다 } diff --git a/src/main/java/com/ampersand/groom/domain/member/persistence/repository/custom/MemberRepositoryCustom.java b/src/main/java/com/ampersand/groom/domain/member/persistence/repository/custom/MemberRepositoryCustom.java new file mode 100644 index 0000000..7881ff6 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/member/persistence/repository/custom/MemberRepositoryCustom.java @@ -0,0 +1,12 @@ +package com.ampersand.groom.domain.member.persistence.repository.custom; + +import com.ampersand.groom.domain.member.domain.constant.MemberRole; +import com.ampersand.groom.domain.member.persistence.entity.MemberJpaEntity; + +import java.util.List; + +public interface MemberRepositoryCustom { + List findMembersByCriteria(Long id, String name, Integer generation, String email, Boolean isAvailable, MemberRole role); + + void updatePassword(Long id, String newPassword); +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/member/persistence/repository/custom/MemberRepositoryCustomImpl.java b/src/main/java/com/ampersand/groom/domain/member/persistence/repository/custom/MemberRepositoryCustomImpl.java new file mode 100644 index 0000000..ea3578f --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/member/persistence/repository/custom/MemberRepositoryCustomImpl.java @@ -0,0 +1,69 @@ +package com.ampersand.groom.domain.member.persistence.repository.custom; + +import com.ampersand.groom.domain.member.domain.constant.MemberRole; +import com.ampersand.groom.domain.member.persistence.entity.MemberJpaEntity; +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +import static com.ampersand.groom.domain.member.persistence.entity.QMemberJpaEntity.memberJpaEntity; + +@Repository +@RequiredArgsConstructor +public class MemberRepositoryCustomImpl implements MemberRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + @Transactional + @Override + public void updatePassword(Long id, String newPassword) { + queryFactory + .update(memberJpaEntity) + .set(memberJpaEntity.password, newPassword) + .where(memberJpaEntity.id.eq(id)) + .execute(); + } + + @Override + public List findMembersByCriteria(Long id, String name, Integer generation, String email, Boolean isAvailable, MemberRole role) { + return queryFactory + .selectFrom(memberJpaEntity) + .where( + eqId(id), + containsName(name), + eqGeneration(generation), + containsEmail(email), + eqIsAvailable(isAvailable), + eqRole(role) + ) + .fetch(); + } + + private BooleanExpression eqId(Long id) { + return id != null ? memberJpaEntity.id.eq(id) : null; + } + + private BooleanExpression containsName(String name) { + return name != null ? memberJpaEntity.name.contains(name) : null; + } + + private BooleanExpression eqGeneration(Integer generation) { + return generation != null ? memberJpaEntity.generation.eq(generation) : null; + } + + private BooleanExpression containsEmail(String email) { + return email != null ? memberJpaEntity.email.containsIgnoreCase(email) : null; + } + + private BooleanExpression eqIsAvailable(Boolean isAvailable) { + return isAvailable != null ? memberJpaEntity.isAvailable.eq(isAvailable) : null; + } + + private BooleanExpression eqRole(MemberRole role) { + return role != null ? memberJpaEntity.role.eq(role) : null; + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/member/presentation/MemberWebAdapter.java b/src/main/java/com/ampersand/groom/domain/member/presentation/MemberWebAdapter.java index 2016b69..55a09a3 100644 --- a/src/main/java/com/ampersand/groom/domain/member/presentation/MemberWebAdapter.java +++ b/src/main/java/com/ampersand/groom/domain/member/presentation/MemberWebAdapter.java @@ -1,10 +1,10 @@ package com.ampersand.groom.domain.member.presentation; -import com.ampersand.groom.domain.member.application.MemberApplicationAdapter; +import com.ampersand.groom.domain.member.application.port.MemberApplicationPort; import com.ampersand.groom.domain.member.domain.constant.MemberRole; +import com.ampersand.groom.domain.member.presentation.data.request.UpdateMemberPasswordRequest; import com.ampersand.groom.domain.member.presentation.data.response.GetCurrentMemberResponse; import com.ampersand.groom.domain.member.presentation.data.response.GetMemberResponse; -import com.ampersand.groom.domain.member.presentation.data.request.UpdateMemberPasswordRequest; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -18,11 +18,11 @@ @RequestMapping("/api/v1/members") public class MemberWebAdapter { - private final MemberApplicationAdapter memberApplicationAdapter; + private final MemberApplicationPort memberApplicationPort; @GetMapping public ResponseEntity> getAllMembers() { - return ResponseEntity.status(HttpStatus.OK).body(memberApplicationAdapter.findAllMembers()); + return ResponseEntity.status(HttpStatus.OK).body(memberApplicationPort.findAllMembers()); } @GetMapping("/search") @@ -34,20 +34,21 @@ public ResponseEntity> searchMembers( @RequestParam(value = "isAvailable", required = false) Boolean isAvailable, @RequestParam(value = "role", required = false) MemberRole role ) { - return ResponseEntity.status(HttpStatus.OK).body(memberApplicationAdapter.findMembersByCriteria(id, name, generation, email, isAvailable, role)); + return ResponseEntity.status(HttpStatus.OK).body(memberApplicationPort.findMembersByCriteria(id, name, generation, email, isAvailable, role)); } - @GetMapping("/current") // TODO: 인증/인가 및 booking 관련 로직 구현 시 구현 + @GetMapping("/current") public ResponseEntity getCurrentMember() { - return null; + return ResponseEntity.status(HttpStatus.OK).body(memberApplicationPort.findCurrentMember()); } - @PatchMapping("/{id}/password") + @PatchMapping("/{memberId}/password") @ResponseStatus(HttpStatus.NO_CONTENT) public ResponseEntity updateMemberPassword( - @PathVariable Long id, + @PathVariable(value = "memberId") Long memberId, @Valid @RequestBody UpdateMemberPasswordRequest request ) { - return null; // TODO: 인증/인가 및 Email 전송 로직 구현 시 구현 + memberApplicationPort.updatePassword(memberId, request.currentPassword(), request.newPassword()); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); } -} +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/member/presentation/data/response/GetCurrentMemberResponse.java b/src/main/java/com/ampersand/groom/domain/member/presentation/data/response/GetCurrentMemberResponse.java index b5f68b4..748405a 100644 --- a/src/main/java/com/ampersand/groom/domain/member/presentation/data/response/GetCurrentMemberResponse.java +++ b/src/main/java/com/ampersand/groom/domain/member/presentation/data/response/GetCurrentMemberResponse.java @@ -11,6 +11,6 @@ public record GetCurrentMemberResponse( String email, Boolean isAvailable, MemberRole role, - List booked // TODO: booking 관련 기능 구현 시 변경 + List booked ) { } \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/member/presentation/data/response/GetMemberBookingResponse.java b/src/main/java/com/ampersand/groom/domain/member/presentation/data/response/GetMemberBookingResponse.java new file mode 100644 index 0000000..4b9a220 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/member/presentation/data/response/GetMemberBookingResponse.java @@ -0,0 +1,12 @@ +package com.ampersand.groom.domain.member.presentation.data.response; + +import java.time.LocalDate; +import java.util.List; + +public record GetMemberBookingResponse( + String place, + List participants, + LocalDate date, + String time +) { +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/error/ErrorCode.java b/src/main/java/com/ampersand/groom/global/error/ErrorCode.java index d7d349e..ec53787 100644 --- a/src/main/java/com/ampersand/groom/global/error/ErrorCode.java +++ b/src/main/java/com/ampersand/groom/global/error/ErrorCode.java @@ -7,7 +7,6 @@ @AllArgsConstructor public enum ErrorCode { - // MEMBER MEMBER_ALREADY_EXISTS("Member already exists", 409), MEMBER_NOT_FOUND("Member not found", 404), @@ -25,14 +24,17 @@ public enum ErrorCode { EMAIL_FORMAT_INVALID("Email format invalid", 400), VERIFICATION_CODE_FORMAT_INVALID("Verification code format invalid", 400), + // AUTHENTICATION PASSWORD_INVALID("Password invalid", 401), USER_ALREADY_EXISTS("User already exists", 409), USER_NOT_FOUND("User not found", 404), USER_FORBIDDEN("Email verification is not complete", 403), EMAIL_OR_PASSWORD_EMPTY("email or password is empty", 400), REFRESH_TOKEN_EXPIRED_OR_INVALID("Refresh token expired or invalid", 401), - REFRESH_TOKEN_REQUEST_FORMAT_INVALID("Refresh token request format invalid", 400); + REFRESH_TOKEN_REQUEST_FORMAT_INVALID("Refresh token request format invalid", 400), + AUTHENTICATION_NOT_FOUND("Authentication Object not found", 403), + EMAIL_AUTHENTICATION_TOO_MANY_REQUESTS("Too many email authentication requests", 429); private final String message; private final int httpStatus; diff --git a/src/main/java/com/ampersand/groom/global/security/config/JwtAuthenticationFilter.java b/src/main/java/com/ampersand/groom/global/security/config/JwtAuthenticationFilter.java index 807755d..0623870 100644 --- a/src/main/java/com/ampersand/groom/global/security/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/ampersand/groom/global/security/config/JwtAuthenticationFilter.java @@ -13,6 +13,7 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.util.AntPathMatcher; import org.springframework.web.filter.OncePerRequestFilter; import java.io.IOException; @@ -24,13 +25,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtService jwtService; private final CustomUserDetailsService customUserDetailsService; + private static final AntPathMatcher pathMatcher = new AntPathMatcher(); + @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token = jwtService.resolveToken(request); - if (request.getRequestURI().startsWith("/auth") || request.getRequestURI().equals("/health")) { + String uri = request.getRequestURI(); + + if (uri.startsWith("/auth") + || uri.equals("/health") + || pathMatcher.match("/api/v1/members/**/password", uri)) { filterChain.doFilter(request, response); return; } @@ -42,10 +49,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse new UsernamePasswordAuthenticationToken(email, null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); - } else { - throw new UserNotFoundException(); } - filterChain.doFilter(request, response); } diff --git a/src/main/java/com/ampersand/groom/global/security/config/SecurityConfig.java b/src/main/java/com/ampersand/groom/global/security/config/SecurityConfig.java index 2d9e4b8..ad10f25 100644 --- a/src/main/java/com/ampersand/groom/global/security/config/SecurityConfig.java +++ b/src/main/java/com/ampersand/groom/global/security/config/SecurityConfig.java @@ -33,6 +33,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(authorizeRequests -> authorizeRequests .requestMatchers("/auth/**").permitAll() // TODO: 추후 리팩터링 시 클래스를 분리하거나 새부적이고 통일된 접근 권한 설정이 요할 것 같습니다 + .requestMatchers("/api/v1/members/{memberId}/password").permitAll() .requestMatchers("/api/v1/**").hasAnyAuthority("ROLE_STUDENT", "ROLE_TEACHER", "ROLE_ADMIN") .requestMatchers("/health").permitAll() .anyRequest().authenticated() diff --git a/src/main/java/com/ampersand/groom/global/thirdParty/EmailConfig.java b/src/main/java/com/ampersand/groom/global/thirdParty/EmailConfig.java index 90ca511..b6c80da 100644 --- a/src/main/java/com/ampersand/groom/global/thirdParty/EmailConfig.java +++ b/src/main/java/com/ampersand/groom/global/thirdParty/EmailConfig.java @@ -34,6 +34,9 @@ public JavaMailSender emailSender() { Properties properties = new Properties(); properties.put("mail.smtp.auth", "true"); properties.put("mail.smtp.starttls.enable", "true"); + properties.put("mail.smtp.starttls.required", "true"); + properties.put("mail.smtp.connectiontimeout", "5000"); + properties.put("mail.smtp.timeout", "5000"); mailSender.setJavaMailProperties(properties); return mailSender; diff --git a/src/test/java/com/ampersand/groom/domain/member/application/usecase/FindCurrentMemberUseCaseTest.java b/src/test/java/com/ampersand/groom/domain/member/application/usecase/FindCurrentMemberUseCaseTest.java new file mode 100644 index 0000000..da31ff6 --- /dev/null +++ b/src/test/java/com/ampersand/groom/domain/member/application/usecase/FindCurrentMemberUseCaseTest.java @@ -0,0 +1,126 @@ +package com.ampersand.groom.domain.member.application.usecase; + +import com.ampersand.groom.domain.booking.application.port.BookingPersistencePort; +import com.ampersand.groom.domain.booking.domain.Booking; +import com.ampersand.groom.domain.booking.domain.Place; +import com.ampersand.groom.domain.booking.domain.TimeSlot; +import com.ampersand.groom.domain.booking.domain.constant.TimeSlotId; +import com.ampersand.groom.domain.member.application.port.MemberPersistencePort; +import com.ampersand.groom.domain.member.domain.Member; +import com.ampersand.groom.domain.member.domain.constant.MemberRole; +import com.ampersand.groom.domain.member.persistence.mapper.MemberMapper; +import com.ampersand.groom.domain.member.presentation.data.response.GetCurrentMemberResponse; +import com.ampersand.groom.domain.member.presentation.data.response.GetMemberResponse; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertAll; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("FindCurrentMemberUseCase 클래스의") +class FindCurrentMemberUseCaseTest { + + @Mock + private MemberPersistencePort memberPersistencePort; + + @Mock + private BookingPersistencePort bookingPersistencePort; + + @Mock + private MemberMapper memberMapper; + + @InjectMocks + private FindCurrentMemberUseCase findCurrentMemberUseCase; + + private void mockSecurityContext(String email) { + Authentication authentication = mock(Authentication.class); + when(authentication.getPrincipal()).thenReturn(email); + SecurityContext context = mock(SecurityContext.class); + when(context.getAuthentication()).thenReturn(authentication); + SecurityContextHolder.setContext(context); + } + + @Nested + @DisplayName("execute 메서드는") + class Describe_execute { + + @Nested + @DisplayName("현재 로그인한 회원이 존재할 때") + class Context_with_logged_in_member { + + @Test + @DisplayName("회원 정보와 예약 정보를 포함한 응답을 반환한다.") + void it_returns_current_member_info_with_bookings() { + // Given + String email = "s00001@gsm.hs.kr"; + mockSecurityContext(email); + Member currentMember = Member.builder() + .id(1L) + .name("홍길동") + .email(email) + .generation(13) + .role(MemberRole.ROLE_STUDENT) + .isAvailable(true) + .build(); + Place place = Place.builder().placeName("탁구대").build(); + TimeSlotId timeSlotId = new TimeSlotId(1L, "오후"); + TimeSlot timeSlot = TimeSlot.builder().timeSlotId(timeSlotId).place(place).build(); + Member participant1 = Member.builder() + .id(2L) + .name("성춘향") + .generation(14) + .email("s00002@gsm.hs.kr") + .role(MemberRole.ROLE_STUDENT) + .isAvailable(true) + .build(); + Member participant2 = Member.builder() + .id(3L) + .name("이몽룡") + .generation(15) + .email("s00003@gsm.hs.kr") + .role(MemberRole.ROLE_STUDENT) + .isAvailable(true) + .build(); + Booking booking = Booking.builder() + .timeSlot(timeSlot) + .bookingDate(LocalDate.of(2024, 3, 30)) + .participants(List.of(participant1, participant2)) + .build(); + when(memberPersistencePort.findMemberByEmail(email)).thenReturn(currentMember); + when(bookingPersistencePort.findBookingByMemberId(currentMember.getId())).thenReturn(List.of(booking)); + when(memberMapper.toResponse(participant1)).thenReturn(new GetMemberResponse(participant1.getId(), participant1.getName(), participant1.getGeneration(), participant1.getEmail(), participant1.getIsAvailable(), participant1.getRole())); + when(memberMapper.toResponse(participant2)).thenReturn(new GetMemberResponse(participant2.getId(), participant2.getName(), participant2.getGeneration(), participant2.getEmail(), participant2.getIsAvailable(), participant2.getRole())); + + // When + GetCurrentMemberResponse response = findCurrentMemberUseCase.execute(); + + // Then + assertAll( + () -> assertEquals(currentMember.getId(), response.id()), + () -> assertEquals(currentMember.getName(), response.name()), + () -> assertEquals(currentMember.getEmail(), response.email()), + () -> assertEquals(currentMember.getRole(), response.role()), + () -> assertEquals(1, response.booked().size()), + () -> assertEquals("탁구대", response.booked().getFirst().place()), + () -> assertEquals("오후", response.booked().getFirst().time()), + () -> assertEquals(LocalDate.of(2024, 3, 30), response.booked().getFirst().date()), + () -> assertEquals(2, response.booked().getFirst().participants().size()) + ); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/ampersand/groom/domain/member/application/usecase/UpdatePasswordUseCaseTest.java b/src/test/java/com/ampersand/groom/domain/member/application/usecase/UpdatePasswordUseCaseTest.java new file mode 100644 index 0000000..9228b02 --- /dev/null +++ b/src/test/java/com/ampersand/groom/domain/member/application/usecase/UpdatePasswordUseCaseTest.java @@ -0,0 +1,127 @@ +package com.ampersand.groom.domain.member.application.usecase; + +import com.ampersand.groom.domain.auth.application.port.AuthenticationPersistencePort; +import com.ampersand.groom.domain.auth.domain.Authentication; +import com.ampersand.groom.domain.auth.exception.PasswordInvalidException; +import com.ampersand.groom.domain.auth.exception.UserForbiddenException; +import com.ampersand.groom.domain.member.application.port.MemberPersistencePort; +import com.ampersand.groom.domain.member.domain.Member; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("UpdatePasswordUseCase 클래스의") +class UpdatePasswordUseCaseTest { + + @Mock + private AuthenticationPersistencePort authenticationPersistencePort; + + @Mock + private MemberPersistencePort memberPersistencePort; + + @Mock + private BCryptPasswordEncoder passwordEncoder; + + @InjectMocks + private UpdatePasswordUseCase updatePasswordUseCase; + + @Nested + @DisplayName("execute 메서드는") + class Describe_execute { + + @Nested + @DisplayName("사용자 인증 정보가 없거나 인증되지 않은 경우") + class Context_with_unverified_user { + + @Test + @DisplayName("UserForbiddenException을 던진다.") + void it_throws_UserForbiddenException() { + // Given + Long memberId = 1L; + String email = "s00001@email.com"; + Member member = Member.builder() + .id(memberId) + .email(email) + .build(); + when(memberPersistencePort.findMemberById(memberId)).thenReturn(member); + when(authenticationPersistencePort.existsAuthenticationByEmail(email)).thenReturn(true); + when(authenticationPersistencePort.findAuthenticationByEmail(email)) + .thenReturn(Authentication.builder().email(email).verified(false).build()); + + // When & Then + assertThrows(UserForbiddenException.class, () -> + updatePasswordUseCase.execute(memberId, "oldPass", "newPass")); + } + } + + @Nested + @DisplayName("현재 비밀번호가 올바르지 않은 경우") + class Context_with_invalid_password { + + @Test + @DisplayName("PasswordInvalidException을 던진다.") + void it_throws_PasswordInvalidException() { + // Given + Long memberId = 1L; + String email = "s00001@email.com"; + String encodedPassword = "encodedPassword"; + Member member = Member.builder() + .id(memberId) + .email(email) + .password(encodedPassword) + .build(); + when(memberPersistencePort.findMemberById(memberId)).thenReturn(member); + when(authenticationPersistencePort.existsAuthenticationByEmail(email)).thenReturn(true); + when(authenticationPersistencePort.findAuthenticationByEmail(email)) + .thenReturn(Authentication.builder().email(email).verified(true).build()); + when(passwordEncoder.matches("oldPass", encodedPassword)).thenReturn(false); + + // When & Then + assertThrows(PasswordInvalidException.class, () -> + updatePasswordUseCase.execute(memberId, "oldPass", "newPass")); + } + } + + @Nested + @DisplayName("모든 조건이 충족되는 경우") + class Context_with_valid_password_update { + + @Test + @DisplayName("비밀번호를 성공적으로 변경한다.") + void it_updates_password_successfully() { + // Given + Long memberId = 1L; + String email = "s00001@email.com"; + String encodedPassword = "encodedOld"; + String newEncodedPassword = "encodedNew"; + Member member = Member.builder() + .id(memberId) + .email(email) + .password(encodedPassword) + .build(); + when(memberPersistencePort.findMemberById(memberId)).thenReturn(member); + when(authenticationPersistencePort.existsAuthenticationByEmail(email)).thenReturn(true); + when(authenticationPersistencePort.findAuthenticationByEmail(email)) + .thenReturn(Authentication.builder().email(email).verified(true).build()); + when(passwordEncoder.matches("oldPass", encodedPassword)).thenReturn(true); + when(passwordEncoder.encode("newPass")).thenReturn(newEncodedPassword); + + // When + updatePasswordUseCase.execute(memberId, "oldPass", "newPass"); + + // Then + verify(memberPersistencePort).updateMemberPassword(memberId, newEncodedPassword); + } + } + } +} \ No newline at end of file