diff --git a/.env.example b/.env.example index 5e398be..fd33287 100644 --- a/.env.example +++ b/.env.example @@ -12,4 +12,6 @@ REDIS_PASSWORD=string EMAIL_HOST=string EMAIL_PORT=number EMAIL_USERNAME=string -EMAIL_PASSWORD=string \ No newline at end of file +EMAIL_PASSWORD=string +EMAIL_AUTHENTICATION_OBJECT_TTL=number +EMAIL_AUTHENTICATION_OBJECT_ATTEMPT_COUNT_LIMIT=number \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 0d5be0d..0c9f514 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation("org.springframework.boot:spring-boot-starter-validation") implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-cache") + implementation("org.springframework.boot:spring-boot-starter-thymeleaf") /** AOP */ implementation("org.aspectj:aspectjweaver:1.9.19") diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/AuthApplicationAdapter.java b/src/main/java/com/ampersand/groom/domain/auth/application/AuthApplicationAdapter.java new file mode 100644 index 0000000..a7409a3 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/application/AuthApplicationAdapter.java @@ -0,0 +1,45 @@ +package com.ampersand.groom.domain.auth.application; + +import com.ampersand.groom.domain.auth.application.port.AuthApplicationPort; +import com.ampersand.groom.domain.auth.application.usecase.*; +import com.ampersand.groom.domain.auth.presentation.data.response.AuthTokenResponse; +import com.ampersand.groom.global.annotation.adapter.Adapter; +import com.ampersand.groom.global.annotation.adapter.constant.AdapterType; +import jakarta.mail.MessagingException; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@Adapter(AdapterType.INBOUND) +public class AuthApplicationAdapter implements AuthApplicationPort { + + private final SignUpUseCase signUpUseCase; + private final SignInUseCase signInUseCase; + private final RefreshUseCase refreshUseCase; + private final VerifyEmailUseCase verifyEmailUseCase; + private final SendAuthenticationEmailUseCase sendAuthenticationEmailUseCase; + + @Override + public void signUp(String email, String password, String name) { + signUpUseCase.execute(email, password, name); + } + + @Override + public AuthTokenResponse signIn(String username, String password) { + return signInUseCase.execute(username, password); + } + + @Override + public AuthTokenResponse refresh(String refreshToken) { + return refreshUseCase.execute(refreshToken); + } + + @Override + public void verifyEmail(String code) { + verifyEmailUseCase.execute(code); + } + + @Override + public void sendAuthenticationEmail(String email) throws MessagingException { + sendAuthenticationEmailUseCase.execute(email); + } +} diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/port/AuthApplicationPort.java b/src/main/java/com/ampersand/groom/domain/auth/application/port/AuthApplicationPort.java new file mode 100644 index 0000000..26506f1 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/application/port/AuthApplicationPort.java @@ -0,0 +1,17 @@ +package com.ampersand.groom.domain.auth.application.port; + +import com.ampersand.groom.domain.auth.presentation.data.response.AuthTokenResponse; +import jakarta.mail.MessagingException; + +public interface AuthApplicationPort { + + void signUp(String email, String password, String name); + + AuthTokenResponse signIn(String username, String password); + + AuthTokenResponse refresh(String refreshToken); + + void verifyEmail(String code); + + void sendAuthenticationEmail(String email) throws MessagingException; +} \ 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/AuthCodePersistencePort.java similarity index 61% rename from src/main/java/com/ampersand/groom/domain/auth/application/port/EmailVerificationPort.java rename to src/main/java/com/ampersand/groom/domain/auth/application/port/AuthCodePersistencePort.java index 6d5f63b..d2ffaeb 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/AuthCodePersistencePort.java @@ -2,17 +2,12 @@ import com.ampersand.groom.domain.auth.domain.AuthCode; -public interface EmailVerificationPort { - - // 코드로 인증 코드 존재 여부 조회 +public interface AuthCodePersistencePort { Boolean existsAuthCodeByCode(String code); - // 코드로 인증 코드 조회 AuthCode findAuthCodeByCode(String code); - // 인증 코드 저장 void saveAuthCode(AuthCode authCode); - // 인증 코드 삭제 void deleteAuthCodeByCode(String code); } \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/port/AuthPort.java b/src/main/java/com/ampersand/groom/domain/auth/application/port/AuthPort.java deleted file mode 100644 index e7e7d6f..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/application/port/AuthPort.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.ampersand.groom.domain.auth.application.port; - -import com.ampersand.groom.domain.member.persistence.entity.MemberJpaEntity; - -import java.util.Optional; - -public interface AuthPort { - - Optional findMembersByCriteria(String email); - - void save(MemberJpaEntity member); -} 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 deleted file mode 100644 index 2253505..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/application/service/AuthService.java +++ /dev/null @@ -1,130 +0,0 @@ -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; -import com.ampersand.groom.domain.member.domain.constant.MemberRole; -import com.ampersand.groom.domain.member.persistence.entity.MemberJpaEntity; -import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.security.crypto.password.PasswordEncoder; -import org.springframework.stereotype.Service; - -import java.time.Instant; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -@Service -@RequiredArgsConstructor -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; - - @Value("${spring.jwt.token.refresh-expiration}") - private long refreshTokenExpiration; - - public JwtToken signIn(String email, String password) { - MemberJpaEntity user = findUserByEmail(email); - validateUserStatus(user); - validatePassword(password, user.getPassword()); - return generateJwtToken(email, user.getRole()); - } - - public JwtToken refreshToken(String refreshToken) { - validateRefreshToken(refreshToken); - String email = jwtService.getEmailFromToken(refreshToken); - validateToken(email, refreshToken); - MemberJpaEntity user = findUserByEmail(email); - return generateJwtToken(email, user.getRole()); - } - - 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); - } - - private MemberJpaEntity findUserByEmail(String email) { - return authPort.findMembersByCriteria(email) - .orElseThrow(UserNotFoundException::new); - } - - private void validateUserStatus(MemberJpaEntity user) { - if (!user.getIsAvailable()) { - throw new UserForbiddenException(); - } - } - - private void validatePassword(String rawPassword, String encodedPassword) { - if (!passwordEncoder.matches(rawPassword, encodedPassword)) { - throw new PasswordInvalidException(); - } - } - - private JwtToken generateJwtToken(String email, MemberRole role) { - String accessToken = jwtService.createAccessToken(email, role); - String refreshToken = jwtService.createRefreshToken(email, role); - return JwtToken.builder() - .accessToken(accessToken) - .refreshToken(refreshToken) - .accessTokenExpiration(Instant.now().plusMillis(accessTokenExpiration)) - .refreshTokenExpiration(Instant.now().plusMillis(refreshTokenExpiration)) - .role(role) - .build(); - } - - private void validateRefreshToken(String refreshToken) { - if (refreshToken == null || refreshToken.isEmpty()) { - throw new RefreshTokenRequestFormatInvalidException(); - } - } - - private void validateToken(String email, String refreshToken) { - if (!jwtService.refreshToken(email, refreshToken) || !jwtService.validateToken(refreshToken)) { - throw new RefreshTokenExpiredOrInvalidException(); - } - } - - private void checkUserExists(String email) { - authPort.findMembersByCriteria(email) - .ifPresent(user -> { - throw new UserExistException(); - }); - } - - private int calculateGenerationFromEmail(String email) { - try { - Matcher matcher = Pattern.compile("\\d{2}").matcher(email); - if (!matcher.find()) { - throw new EmailFormatInvalidException(); - } - int admissionYear = Integer.parseInt(matcher.group()) + 2000; - return (admissionYear - 2017) + 1; - } catch (NumberFormatException e) { - throw new EmailFormatInvalidException(); - } - } - - private MemberJpaEntity createNewUser(SignupRequest request, int generation) { - return MemberJpaEntity.builder() - .name(request.getName()) - .email(request.getEmail()) - .password(passwordEncoder.encode(request.getPassword())) - .generation(generation) - .isAvailable(true) - .role(MemberRole.ROLE_STUDENT) - .build(); - } -} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/service/CustomUserDetailsService.java b/src/main/java/com/ampersand/groom/domain/auth/application/service/CustomUserDetailsService.java deleted file mode 100644 index f528bcc..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/application/service/CustomUserDetailsService.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ampersand.groom.domain.auth.application.service; - -import com.ampersand.groom.domain.auth.application.port.AuthPort; -import com.ampersand.groom.domain.auth.exception.UserNotFoundException; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.userdetails.User; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.security.core.userdetails.UserDetailsService; -import org.springframework.stereotype.Service; - -import java.util.Collections; - -@Service -@RequiredArgsConstructor -public class CustomUserDetailsService implements UserDetailsService { - - private final AuthPort authPort; - - @Override - public UserDetails loadUserByUsername(String email) { - return authPort.findMembersByCriteria(email) - .map(member -> { - SimpleGrantedAuthority authority = new SimpleGrantedAuthority(member.getRole().name()); - return new User(member.getEmail(), member.getPassword(), Collections.singletonList(authority)); - }) - .orElseThrow(() -> new UserNotFoundException()); - } -} 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 deleted file mode 100644 index 14141db..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/application/service/EmailVerificationService.java +++ /dev/null @@ -1,145 +0,0 @@ -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 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; - - // 인증 코드 생성 - private String generateVerificationCode() { - 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); - message.setSubject(subject); - message.setText(text); - javaMailSender.send(message); - } - - // 인증 이메일 전송 (회원가입 / 비밀번호 재설정 공통 처리) - private void sendVerificationEmail(String email, String subject) { - validateEmailFormat(email); - checkAttemptCount(email); - increaseAttemptCount(email); - - String code = generateVerificationCode(); - sendEmail(email, subject, "귀하의 인증 코드는: " + code); - - AuthCode authCode = AuthCode.builder() - .email(email) - .code(code) - .ttl(TTL) - .build(); - - emailVerificationPort.saveAuthCode(authCode); - } - - // 회원가입용 인증 메일 전송 - public void sendSignupVerificationEmail(String email) { - sendVerificationEmail(email, "회원가입 인증"); - } - - // 비밀번호 재설정 인증 메일 전송 - public void sendPasswordResetEmail(String email) { - sendVerificationEmail(email, "비밀번호 변경 인증"); - } - - // 인증 코드 검증 - public void verifyCode(String code) { - if (code == null || code.length() != CODE_LENGTH) { - throw new VerificationCodeFormatInvalidException(); - } - - if (!emailVerificationPort.existsAuthCodeByCode(code)) { - throw new VerificationCodeExpiredOrInvalidException(); - } - - AuthCode authCode = emailVerificationPort.findAuthCodeByCode(code); - emailVerificationPort.deleteAuthCodeByCode(code); - markEmailVerified(authCode.getEmail()); - } - - // 이메일 형식 검증 - 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/application/service/JwtService.java b/src/main/java/com/ampersand/groom/domain/auth/application/service/JwtService.java deleted file mode 100644 index b6d724a..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/application/service/JwtService.java +++ /dev/null @@ -1,84 +0,0 @@ -package com.ampersand.groom.domain.auth.application.service; - -import com.ampersand.groom.domain.member.domain.constant.MemberRole; -import io.jsonwebtoken.*; -import jakarta.servlet.http.HttpServletRequest; -import lombok.Getter; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; -import org.springframework.stereotype.Service; - -import javax.crypto.SecretKey; -import java.util.Date; -import java.util.concurrent.TimeUnit; - -@Service -@RequiredArgsConstructor -public class JwtService { - - private final RedisTemplate redisTemplate; - private final SecretKey secretKey; - @Getter - private final long accessTokenExpiration; - private final long refreshTokenExpiration; - - public String createAccessToken(String email, MemberRole roles) { - return generateToken(email, accessTokenExpiration, roles); - } - - public String createRefreshToken(String email, MemberRole roles) { - String refreshToken = generateToken(email, refreshTokenExpiration, roles); - redisTemplate.opsForValue().set("refresh_token:" + email, refreshToken, refreshTokenExpiration, TimeUnit.SECONDS); - return refreshToken; - } - - public boolean refreshToken(String email, String refreshToken) { - String storedToken = redisTemplate.opsForValue().get("refresh_token:" + email); - - return storedToken != null && storedToken.equals(refreshToken); - } - - private String generateToken(String subject, long expirationMs, MemberRole roles) { - Date now = new Date(); - return Jwts.builder() - .claim("sub", subject) - .claim("iat", now.getTime()) - .claim("exp", now.getTime() + expirationMs) - .claim("role", roles) - .signWith(secretKey) - .compact(); - } - - public String getEmailFromToken(String token) { - return parseClaims(token).getSubject(); - } - - public MemberRole getRoleFromToken(String token) { - Claims claims = parseClaims(token); - String role = claims.get("role", String.class); - return role != null ? MemberRole.valueOf(role) : null; - } - - public boolean validateToken(String token) { - try { - parseClaims(token); - return true; - } catch (JwtException | IllegalArgumentException e) { - return false; - } - } - - private Claims parseClaims(String token) { - return Jwts.parser() - .verifyWith(secretKey) - .build() - .parseSignedClaims(token) - .getPayload(); - } - - public String resolveToken(HttpServletRequest request) { - String token = request.getHeader("Authorization"); - return (token != null && token.startsWith("Bearer ")) ? token.substring(7) : null; - } -} diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/usecase/EmailVerificationUseCase.java b/src/main/java/com/ampersand/groom/domain/auth/application/usecase/EmailVerificationUseCase.java deleted file mode 100644 index 4f07dad..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/application/usecase/EmailVerificationUseCase.java +++ /dev/null @@ -1,29 +0,0 @@ -package com.ampersand.groom.domain.auth.application.usecase; - -import com.ampersand.groom.domain.auth.application.service.EmailVerificationService; -import com.ampersand.groom.global.annotation.usecase.UseCaseWithTransaction; -import lombok.RequiredArgsConstructor; - -@UseCaseWithTransaction -@RequiredArgsConstructor -public class EmailVerificationUseCase { - - private final EmailVerificationService emailVerificationService; - - - // 회원가입 인증 이메일 전송 - public void executeSendSignupVerificationEmail(String email) { - emailVerificationService.sendSignupVerificationEmail(email); - } - - // 비밀번호 변경 인증 이메일 전송 - public void executeSendPasswordResetEmail(String email) { - emailVerificationService.sendPasswordResetEmail(email); - } - - // 인증 코드 검증 - public void executeVerifyCode(String code) { - emailVerificationService.verifyCode(code); - } - -} diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/usecase/RefreshUseCase.java b/src/main/java/com/ampersand/groom/domain/auth/application/usecase/RefreshUseCase.java new file mode 100644 index 0000000..d1343bb --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/application/usecase/RefreshUseCase.java @@ -0,0 +1,41 @@ +package com.ampersand.groom.domain.auth.application.usecase; + +import com.ampersand.groom.domain.auth.exception.RefreshTokenExpiredOrInvalidException; +import com.ampersand.groom.domain.auth.presentation.data.response.AuthTokenResponse; +import com.ampersand.groom.domain.member.application.port.MemberPersistencePort; +import com.ampersand.groom.domain.member.domain.Member; +import com.ampersand.groom.global.annotation.usecase.UseCaseWithReadOnlyTransaction; +import com.ampersand.groom.global.security.jwt.data.TokenDto; +import com.ampersand.groom.global.security.jwt.service.JwtIssueService; +import com.ampersand.groom.global.security.jwt.service.JwtParserService; +import com.ampersand.groom.global.security.jwt.service.JwtRefreshManagementService; +import lombok.RequiredArgsConstructor; + +@UseCaseWithReadOnlyTransaction +@RequiredArgsConstructor +public class RefreshUseCase { + + private final JwtIssueService jwtIssueService; + private final JwtParserService jwtParserService; + private final JwtRefreshManagementService jwtRefreshManagementService; + private final MemberPersistencePort memberPersistencePort; + + public AuthTokenResponse execute(String refreshToken) { + if (jwtParserService.validateRefreshToken(refreshToken)) { + String email = jwtParserService.getEmailFromRefreshToken(refreshToken); + Member member = memberPersistencePort.findMemberByEmail(email); + jwtRefreshManagementService.deleteRefreshToken(refreshToken); + TokenDto newAccessToken = jwtIssueService.issueAccessToken(email, member.getRole()); + TokenDto newRefreshToken = jwtIssueService.issueRefreshToken(email); + return new AuthTokenResponse( + newAccessToken.token(), + newRefreshToken.token(), + newAccessToken.expiration(), + newRefreshToken.expiration(), + member.getRole() + ); + } else { + throw new RefreshTokenExpiredOrInvalidException(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/usecase/SendAuthenticationEmailUseCase.java b/src/main/java/com/ampersand/groom/domain/auth/application/usecase/SendAuthenticationEmailUseCase.java new file mode 100644 index 0000000..129ff1a --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/application/usecase/SendAuthenticationEmailUseCase.java @@ -0,0 +1,57 @@ +package com.ampersand.groom.domain.auth.application.usecase; + +import com.ampersand.groom.domain.auth.application.port.AuthCodePersistencePort; +import com.ampersand.groom.domain.auth.application.port.AuthenticationPersistencePort; +import com.ampersand.groom.domain.auth.application.util.GenerationAuthCode; +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.global.annotation.usecase.UseCase; +import com.ampersand.groom.infrastructure.thirdparty.email.service.EmailSendService; +import jakarta.mail.MessagingException; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; + +@UseCase +@RequiredArgsConstructor +public class SendAuthenticationEmailUseCase { + + private final AuthCodePersistencePort authCodePersistencePort; + private final AuthenticationPersistencePort authenticationPersistencePort; + private final EmailSendService emailSendService; + @Value("${email.authentication.object.ttl}") + private Long ttl; + @Value("${email.authentication.object.attempt-count-limit}") + private Integer attemptCountLimit; + + public void execute(String email) throws MessagingException { + if (authenticationPersistencePort.existsAuthenticationByEmail(email)) { + Authentication authentication = authenticationPersistencePort.findAuthenticationByEmail(email); + if (authentication.getAttemptCount() >= attemptCountLimit) { + throw new EmailAuthRateLimitException(); + } + Authentication updatedAuthentication = Authentication.builder() + .email(authentication.getEmail()) + .attemptCount(authentication.getAttemptCount() + 1) + .verified(authentication.getVerified()) + .ttl(authentication.getTtl()) + .build(); + authenticationPersistencePort.saveAuthentication(updatedAuthentication); + } else { + Authentication newAuthentication = Authentication.builder() + .email(email) + .attemptCount(1) + .verified(false) + .ttl(ttl) + .build(); + authenticationPersistencePort.saveAuthentication(newAuthentication); + } + AuthCode authCode = AuthCode.builder() + .email(email) + .code(GenerationAuthCode.generateAuthCode()) + .ttl(ttl) + .build(); + authCodePersistencePort.saveAuthCode(authCode); + emailSendService.sendMail(email, authCode.getCode()); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/usecase/SignInUseCase.java b/src/main/java/com/ampersand/groom/domain/auth/application/usecase/SignInUseCase.java new file mode 100644 index 0000000..a933732 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/application/usecase/SignInUseCase.java @@ -0,0 +1,37 @@ +package com.ampersand.groom.domain.auth.application.usecase; + +import com.ampersand.groom.domain.auth.exception.PasswordInvalidException; +import com.ampersand.groom.domain.auth.presentation.data.response.AuthTokenResponse; +import com.ampersand.groom.domain.member.application.port.MemberPersistencePort; +import com.ampersand.groom.domain.member.domain.Member; +import com.ampersand.groom.global.annotation.usecase.UseCaseWithReadOnlyTransaction; +import com.ampersand.groom.global.security.jwt.data.TokenDto; +import com.ampersand.groom.global.security.jwt.service.JwtIssueService; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@UseCaseWithReadOnlyTransaction +@RequiredArgsConstructor +public class SignInUseCase { + + private final JwtIssueService jwtIssueService; + private final MemberPersistencePort memberPersistencePort; + private final BCryptPasswordEncoder passwordEncoder; + + public AuthTokenResponse execute(String username, String password) { + Member member = memberPersistencePort.findMemberByEmail(username); + if (passwordEncoder.matches(password, member.getPassword())) { + TokenDto accessToken = jwtIssueService.issueAccessToken(member.getEmail(), member.getRole()); + TokenDto refreshToken = jwtIssueService.issueRefreshToken(member.getEmail()); + return new AuthTokenResponse( + accessToken.token(), + refreshToken.token(), + accessToken.expiration(), + refreshToken.expiration(), + member.getRole() + ); + } else { + throw new PasswordInvalidException(); + } + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/usecase/SignUpUseCase.java b/src/main/java/com/ampersand/groom/domain/auth/application/usecase/SignUpUseCase.java new file mode 100644 index 0000000..1be8e6d --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/application/usecase/SignUpUseCase.java @@ -0,0 +1,59 @@ +package com.ampersand.groom.domain.auth.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.EmailFormatInvalidException; +import com.ampersand.groom.domain.auth.exception.UserExistException; +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.domain.member.domain.constant.MemberRole; +import com.ampersand.groom.global.annotation.usecase.UseCaseWithTransaction; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +@UseCaseWithTransaction +@RequiredArgsConstructor +public class SignUpUseCase { + + private final BCryptPasswordEncoder passwordEncoder; + private final MemberPersistencePort memberPersistencePort; + private final AuthenticationPersistencePort authenticationPersistencePort; + + public void execute(String email, String password, String name) { + Authentication authentication = authenticationPersistencePort.findAuthenticationByEmail(email); + if (authentication == null || Boolean.FALSE.equals(authentication.getVerified())) { + throw new UserForbiddenException(); + } + if (memberPersistencePort.existsMemberByEmail(email)) { + throw new UserExistException(); + } + int generation = calculateGenerationFromEmail(email); + Member newUser = Member.builder() + .name(name) + .email(email) + .password(passwordEncoder.encode(password)) + .generation(generation) + .isAvailable(true) + .role(MemberRole.ROLE_STUDENT) + .build(); + + memberPersistencePort.saveMember(newUser); + } + + private int calculateGenerationFromEmail(String email) { + Matcher matcher = Pattern.compile("\\d{2}").matcher(email); + if (matcher.find()) { + try { + int admissionYear = Integer.parseInt(matcher.group()) + 2000; + return (admissionYear - 2017) + 1; + } catch (NumberFormatException e) { + throw new EmailFormatInvalidException(); + } + } + throw new EmailFormatInvalidException(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/application/usecase/VerifyEmailUseCase.java b/src/main/java/com/ampersand/groom/domain/auth/application/usecase/VerifyEmailUseCase.java new file mode 100644 index 0000000..de3fa2d --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/application/usecase/VerifyEmailUseCase.java @@ -0,0 +1,33 @@ +package com.ampersand.groom.domain.auth.application.usecase; + +import com.ampersand.groom.domain.auth.application.port.AuthCodePersistencePort; +import com.ampersand.groom.domain.auth.application.port.AuthenticationPersistencePort; +import com.ampersand.groom.domain.auth.domain.AuthCode; +import com.ampersand.groom.domain.auth.domain.Authentication; +import com.ampersand.groom.domain.auth.exception.VerificationCodeExpiredOrInvalidException; +import com.ampersand.groom.global.annotation.usecase.UseCase; +import lombok.RequiredArgsConstructor; + +@UseCase +@RequiredArgsConstructor +public class VerifyEmailUseCase { + + private final AuthCodePersistencePort authCodePersistencePort; + private final AuthenticationPersistencePort authenticationPersistencePort; + + public void execute(String code) { + if (!authCodePersistencePort.existsAuthCodeByCode(code)) { + throw new VerificationCodeExpiredOrInvalidException(); + } + AuthCode authCode = authCodePersistencePort.findAuthCodeByCode(code); + authCodePersistencePort.deleteAuthCodeByCode(code); + Authentication authentication = authenticationPersistencePort.findAuthenticationByEmail(authCode.getEmail()); + Authentication updatedAuth = Authentication.builder() + .email(authCode.getEmail()) + .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/application/util/GenerationAuthCode.java b/src/main/java/com/ampersand/groom/domain/auth/application/util/GenerationAuthCode.java new file mode 100644 index 0000000..9fb46db --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/application/util/GenerationAuthCode.java @@ -0,0 +1,12 @@ +package com.ampersand.groom.domain.auth.application.util; + +import lombok.experimental.UtilityClass; + +import java.security.SecureRandom; + +@UtilityClass +public class GenerationAuthCode { + public String generateAuthCode() { + return String.valueOf(10000000 + new SecureRandom().nextInt(90000000)); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/domain/JwtToken.java b/src/main/java/com/ampersand/groom/domain/auth/domain/JwtToken.java deleted file mode 100644 index e1c849f..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/domain/JwtToken.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.ampersand.groom.domain.auth.domain; - -import com.ampersand.groom.domain.member.domain.constant.MemberRole; -import lombok.Builder; -import lombok.Getter; - -import java.time.Instant; - -@Getter -@Builder -public class JwtToken { - - private String accessToken; - private String refreshToken; - private Instant accessTokenExpiration; - private Instant refreshTokenExpiration; - private MemberRole role; -} 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/AuthCodePersistenceAdapter.java similarity index 78% rename from src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/email/EmailVerificationAdapter.java rename to src/main/java/com/ampersand/groom/domain/auth/persistence/AuthCodePersistenceAdapter.java index abec489..36a2fa2 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/AuthCodePersistenceAdapter.java @@ -1,6 +1,6 @@ -package com.ampersand.groom.domain.auth.persistence.adapter.email; +package com.ampersand.groom.domain.auth.persistence; -import com.ampersand.groom.domain.auth.application.port.EmailVerificationPort; +import com.ampersand.groom.domain.auth.application.port.AuthCodePersistencePort; 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; @@ -10,9 +10,8 @@ @Adapter(AdapterType.OUTBOUND) @RequiredArgsConstructor -public class EmailVerificationAdapter implements EmailVerificationPort { +public class AuthCodePersistenceAdapter implements AuthCodePersistencePort { - // private final JpaEmailVerificationRepository jpaEmailVerificationRepository; private final AuthCodeRedisRepository authCodeRedisRepository; private final AuthCodeMapper authCodeMapper; 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/AuthenticationPersistenceAdapter.java similarity index 95% rename from src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/email/AuthenticationPersistenceAdapter.java rename to src/main/java/com/ampersand/groom/domain/auth/persistence/AuthenticationPersistenceAdapter.java index 945ffe3..68b887c 100644 --- a/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/email/AuthenticationPersistenceAdapter.java +++ b/src/main/java/com/ampersand/groom/domain/auth/persistence/AuthenticationPersistenceAdapter.java @@ -1,4 +1,4 @@ -package com.ampersand.groom.domain.auth.persistence.adapter.email; +package com.ampersand.groom.domain.auth.persistence; import com.ampersand.groom.domain.auth.application.port.AuthenticationPersistencePort; import com.ampersand.groom.domain.auth.domain.Authentication; diff --git a/src/main/java/com/ampersand/groom/domain/auth/persistence/EmailVerification.java b/src/main/java/com/ampersand/groom/domain/auth/persistence/EmailVerification.java deleted file mode 100644 index 5c565df..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/persistence/EmailVerification.java +++ /dev/null @@ -1,48 +0,0 @@ -package com.ampersand.groom.domain.auth.persistence; - -import jakarta.persistence.*; -import lombok.*; - -import java.time.LocalDateTime; - -@Entity -@Getter -@NoArgsConstructor -@Table(name = "email") -@ToString -public class EmailVerification { - - @Id - @GeneratedValue(strategy = GenerationType.IDENTITY) - private Long id; - @Column(nullable = false) - private String email; - @Column(nullable = false) - private String code; - @Column(nullable = false) - private boolean isVerified; - @Column(nullable = false) - private LocalDateTime verificationDate; - - public EmailVerification(String email, String code) { - this.email = email; - this.code = code; - this.isVerified = false; - this.verificationDate = LocalDateTime.now().plusMinutes(5); - } - - - @Builder - public EmailVerification(Long id, String email, String code, boolean isVerified, LocalDateTime verificationDate) { - this.id = id; - this.email = email; - this.code = code; - this.isVerified = isVerified; - this.verificationDate = verificationDate; - } - - - public void setIsVerified(boolean isVerified) { - this.isVerified = isVerified; - } -} diff --git a/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/auth/AuthPortAdapter.java b/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/auth/AuthPortAdapter.java deleted file mode 100644 index 4908db9..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/persistence/adapter/auth/AuthPortAdapter.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.ampersand.groom.domain.auth.persistence.adapter.auth; - -import com.ampersand.groom.domain.auth.application.port.AuthPort; -import com.ampersand.groom.domain.member.persistence.entity.MemberJpaEntity; -import com.ampersand.groom.domain.member.persistence.repository.MemberJpaRepository; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Component; - -import java.util.Optional; - -@Component -@RequiredArgsConstructor -public class AuthPortAdapter implements AuthPort { - - private final MemberJpaRepository memberJpaRepository; - - @Override - public Optional findMembersByCriteria(String email) { - return memberJpaRepository.findMemberByEmail(email); - } - - - @Override - public void save(MemberJpaEntity member) { - memberJpaRepository.save(member); - } -} diff --git a/src/main/java/com/ampersand/groom/domain/auth/persistence/repository/JpaEmailVerificationRepository.java b/src/main/java/com/ampersand/groom/domain/auth/persistence/repository/JpaEmailVerificationRepository.java deleted file mode 100644 index 46a6c90..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/persistence/repository/JpaEmailVerificationRepository.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.ampersand.groom.domain.auth.persistence.repository; - -import com.ampersand.groom.domain.auth.persistence.EmailVerification; -import lombok.RequiredArgsConstructor; -import org.springframework.stereotype.Repository; - -import java.time.LocalDateTime; -import java.util.Optional; - -@Repository -@RequiredArgsConstructor -public class JpaEmailVerificationRepository { - - private final SpringDataEmailVerificationRepository repository; - - - public EmailVerification save(EmailVerification emailVerification) { - return repository.save(emailVerification); - } - - public Optional findByCode(String code) { - return repository.findByCode(code); - } - - public Optional findByEmail(String email) { - return repository.findByEmail(email); - } - - public void deleteAllExpired(LocalDateTime now) { - repository.deleteAllExpired(now); - } - -} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/persistence/repository/SpringDataEmailVerificationRepository.java b/src/main/java/com/ampersand/groom/domain/auth/persistence/repository/SpringDataEmailVerificationRepository.java deleted file mode 100644 index 499fbe7..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/persistence/repository/SpringDataEmailVerificationRepository.java +++ /dev/null @@ -1,23 +0,0 @@ -package com.ampersand.groom.domain.auth.persistence.repository; - -import com.ampersand.groom.domain.auth.persistence.EmailVerification; -import io.lettuce.core.dynamic.annotation.Param; -import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Modifying; -import org.springframework.data.jpa.repository.Query; -import org.springframework.stereotype.Repository; - -import java.time.LocalDateTime; -import java.util.Optional; - -@Repository -public interface SpringDataEmailVerificationRepository extends JpaRepository { - - Optional findByCode(String code); - - Optional findByEmail(String email); - - @Modifying - @Query("DELETE FROM EmailVerification e WHERE e.verificationDate < :now") - void deleteAllExpired(@Param("now") LocalDateTime now); -} diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/AuthWebAdapter.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/AuthWebAdapter.java new file mode 100644 index 0000000..f3d2d1d --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/AuthWebAdapter.java @@ -0,0 +1,50 @@ +package com.ampersand.groom.domain.auth.presentation; + +import com.ampersand.groom.domain.auth.application.port.AuthApplicationPort; +import com.ampersand.groom.domain.auth.presentation.data.request.*; +import com.ampersand.groom.domain.auth.presentation.data.response.AuthTokenResponse; +import jakarta.mail.MessagingException; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RequestMapping("/api/v1/auth") +@RequiredArgsConstructor +@RestController +public class AuthWebAdapter { + + private final AuthApplicationPort authApplicationPort; + + @PostMapping("/signin") + public ResponseEntity signIn(@Valid @RequestBody SignInRequest request) { + return ResponseEntity.status(HttpStatus.OK).body(authApplicationPort.signIn(request.email(), request.password())); + } + + @PostMapping("/signup") + @ResponseStatus(HttpStatus.CREATED) + public ResponseEntity signUp(@Valid @RequestBody SignUpRequest request) { + authApplicationPort.signUp(request.email(), request.password(), request.name()); + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + + @PutMapping("/refresh") + public ResponseEntity refreshToken(@Valid @RequestBody RefreshRequest request) { + return ResponseEntity.status(HttpStatus.OK).body(authApplicationPort.refresh(request.refreshToken())); + } + + @PatchMapping("/verify-email") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ResponseEntity verifyEmail(@Valid @RequestBody VerificationCodeRequest request) { + authApplicationPort.verifyEmail(request.code()); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } + + @PostMapping("/send-email") + @ResponseStatus(HttpStatus.NO_CONTENT) + public ResponseEntity sendAuthenticationEmail(@Valid @RequestBody SendEmailRequest request) throws MessagingException { + authApplicationPort.sendAuthenticationEmail(request.email()); + return ResponseEntity.status(HttpStatus.NO_CONTENT).build(); + } +} \ 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 deleted file mode 100644 index d8353bb..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/presentation/controller/AuthController.java +++ /dev/null @@ -1,56 +0,0 @@ -package com.ampersand.groom.domain.auth.presentation.controller; - -import com.ampersand.groom.domain.auth.application.service.AuthService; -import com.ampersand.groom.domain.auth.application.usecase.EmailVerificationUseCase; -import com.ampersand.groom.domain.auth.domain.JwtToken; -import com.ampersand.groom.domain.auth.presentation.data.request.*; -import jakarta.validation.Valid; -import lombok.RequiredArgsConstructor; -import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; - -@RestController -@RequestMapping("/auth") -@RequiredArgsConstructor -public class AuthController { - - private final EmailVerificationUseCase emailVerificationUseCase; - private final AuthService authService; - - @PostMapping("/signIn") - public ResponseEntity signIn(@RequestBody @Valid SignInRequest request) { - JwtToken jwtToken = authService.signIn(request.getEmail(), request.getPassword()); - return ResponseEntity.ok(jwtToken); - } - - @PostMapping("/signup") - public ResponseEntity signup(@RequestBody @Valid SignupRequest request) { - authService.signup(request); - return ResponseEntity.status(HttpStatus.CREATED).body("Signup successful"); - } - - @PatchMapping("/refresh") - public ResponseEntity refresh(@RequestBody @Valid RefreshRequest request) { - JwtToken jwtToken = authService.refreshToken(request.getRefreshToken()); - return ResponseEntity.ok(jwtToken); - } - - @PostMapping("/verify-email") - public ResponseEntity verifyEmail(@RequestBody @Valid VerificationCodeRequest request) { - emailVerificationUseCase.executeVerifyCode(request.getCode()); - 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.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.NO_CONTENT).body("Verification email sent"); - } -} diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/EmailRequest.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/EmailRequest.java deleted file mode 100644 index 36cd765..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/EmailRequest.java +++ /dev/null @@ -1,15 +0,0 @@ -package com.ampersand.groom.domain.auth.presentation.data.request; - -import jakarta.validation.constraints.Email; -import lombok.Getter; - -@Getter -public class EmailRequest { - - @Email(message = "Invalid format") - private final String email; - - public EmailRequest(String email) { - this.email = email; - } -} diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/RefreshRequest.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/RefreshRequest.java index 4e09455..02b48be 100644 --- a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/RefreshRequest.java +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/RefreshRequest.java @@ -1,16 +1,9 @@ package com.ampersand.groom.domain.auth.presentation.data.request; import jakarta.validation.constraints.NotBlank; -import lombok.Getter; +import jakarta.validation.constraints.Size; -@Getter -public class RefreshRequest { - - @NotBlank(message = "Refresh token is required.") - private final String refreshToken; - - public RefreshRequest(String refreshToken) { - this.refreshToken = refreshToken; - } - -} +public record RefreshRequest( + @NotBlank @Size(max = 256) String refreshToken +) { +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SendEmailRequest.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SendEmailRequest.java new file mode 100644 index 0000000..d8db2c5 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SendEmailRequest.java @@ -0,0 +1,9 @@ +package com.ampersand.groom.domain.auth.presentation.data.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.Size; + +public record SendEmailRequest( + @Email @Size(max = 16,min = 16) String email +) { +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignInRequest.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignInRequest.java index 2b6da8c..1a27e93 100644 --- a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignInRequest.java +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignInRequest.java @@ -3,24 +3,9 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Size; -import lombok.Getter; -@Getter -public class SignInRequest { - - private static final int EMAIL_MAX_LENGTH = 16; - private static final int PASSWORD_MAX_LENGTH = 30; - - @Email(message = "Invalid email format") - @Size(max = EMAIL_MAX_LENGTH, message = "Invalid email format") - private final String email; - - @NotBlank(message = "Invalid password format") - @Size(max = PASSWORD_MAX_LENGTH, message = "Invalid password format") - private final String password; - - public SignInRequest(String email, String password) { - this.email = email; - this.password = password; - } -} +public record SignInRequest( + @Email @Size(max = 16) String email, + @NotBlank @Size(max = 30) String password +) { +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignUpRequest.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignUpRequest.java new file mode 100644 index 0000000..42988d6 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignUpRequest.java @@ -0,0 +1,12 @@ +package com.ampersand.groom.domain.auth.presentation.data.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record SignUpRequest( + @Email @Size(max = 16) String email, + @NotBlank @Size(max = 30) String password, + @NotBlank @Size(max = 50) String name +) { +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignupRequest.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignupRequest.java deleted file mode 100644 index 8c74c41..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/SignupRequest.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.ampersand.groom.domain.auth.presentation.data.request; - -import jakarta.validation.constraints.Email; -import jakarta.validation.constraints.NotBlank; -import lombok.Getter; - -@Getter -public class SignupRequest { - - @Email(message = "Invalid format") - private final String email; - - @NotBlank(message = "Password cannot be blank") - private final String password; - - @NotBlank(message = "Name cannot be blank") - private final String name; - - public SignupRequest(String email, String password, String name) { - this.email = email; - this.password = password; - this.name = name; - } -} diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/VerificationCodeRequest.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/VerificationCodeRequest.java index 8ef39b0..2bbcad9 100644 --- a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/VerificationCodeRequest.java +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/request/VerificationCodeRequest.java @@ -1,15 +1,8 @@ package com.ampersand.groom.domain.auth.presentation.data.request; import jakarta.validation.constraints.Size; -import lombok.Getter; -@Getter -public class VerificationCodeRequest { - - @Size(min = 8, max = 8, message = "Invalid format") - private final String code; - - public VerificationCodeRequest(String code) { - this.code = code; - } -} +public record VerificationCodeRequest( + @Size(min = 8, max = 8) String code +) { +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/AuthTokenResponse.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/AuthTokenResponse.java new file mode 100644 index 0000000..18702e9 --- /dev/null +++ b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/AuthTokenResponse.java @@ -0,0 +1,14 @@ +package com.ampersand.groom.domain.auth.presentation.data.response; + +import com.ampersand.groom.domain.member.domain.constant.MemberRole; + +import java.time.LocalDateTime; + +public record AuthTokenResponse( + String accessToken, + String refreshToken, + LocalDateTime accessTokenExpiresAt, + LocalDateTime refreshTokenExpiresAt, + MemberRole role +) { +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/RefreshResponse.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/RefreshResponse.java deleted file mode 100644 index f320e32..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/RefreshResponse.java +++ /dev/null @@ -1,24 +0,0 @@ -package com.ampersand.groom.domain.auth.presentation.data.response; - -import java.time.LocalDateTime; - -import com.ampersand.groom.domain.member.domain.Member; -import lombok.Getter; - -@Getter -public class RefreshResponse { - - private final String accessToken; - private final String refreshToken; - private final LocalDateTime accessTokenExpiresAt; - private final LocalDateTime refreshTokenExpiredAt; - private final Member role; - - public RefreshResponse(String accessToken, String refreshToken, LocalDateTime accessTokenExpiresAt, LocalDateTime refreshTokenExpiredAt, Member role) { - this.accessToken = accessToken; - this.refreshToken = refreshToken; - this.accessTokenExpiresAt = accessTokenExpiresAt; - this.refreshTokenExpiredAt = refreshTokenExpiredAt; - this.role = role; - } -} diff --git a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/SignInResponse.java b/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/SignInResponse.java deleted file mode 100644 index 7a80843..0000000 --- a/src/main/java/com/ampersand/groom/domain/auth/presentation/data/response/SignInResponse.java +++ /dev/null @@ -1,25 +0,0 @@ -package com.ampersand.groom.domain.auth.presentation.data.response; - -import com.ampersand.groom.domain.member.domain.Member; -import lombok.Getter; - -import java.time.LocalDateTime; - -@Getter -public class SignInResponse { - - private final String accessToken; - private final String refreshToken; - private final LocalDateTime accessTokenExpiresAt; - private final LocalDateTime refreshTokenExpiresAt; - private final Member role; - - - public SignInResponse(String accessToken, String refreshToken, LocalDateTime accessTokenExpiresAt, LocalDateTime refreshTokenExpiresAt, Member role) { - this.accessToken = accessToken; - this.refreshToken = refreshToken; - this.accessTokenExpiresAt = accessTokenExpiresAt; - this.refreshTokenExpiresAt = refreshTokenExpiresAt; - this.role = role; - } -} diff --git a/src/main/java/com/ampersand/groom/domain/booking/persistence/mapper/BookingMapper.java b/src/main/java/com/ampersand/groom/domain/booking/persistence/mapper/BookingMapper.java index 71d16a2..f49aad6 100644 --- a/src/main/java/com/ampersand/groom/domain/booking/persistence/mapper/BookingMapper.java +++ b/src/main/java/com/ampersand/groom/domain/booking/persistence/mapper/BookingMapper.java @@ -7,10 +7,12 @@ 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.GetMemberResponse; +import com.ampersand.groom.global.entity.BaseIdEntity; import com.ampersand.groom.global.mapper.GenericMapper; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; +import java.util.Comparator; import java.util.stream.Collectors; @Component @@ -25,7 +27,7 @@ public Booking toDomain(BookingJpaEntity bookingJpaEntity) { return Booking.builder() .id(bookingJpaEntity.getId()) .president(memberMapper.toDomain(bookingJpaEntity.getPresident())) - .participants(bookingJpaEntity.getParticipants().stream().map(memberMapper::toDomain).collect(Collectors.toList())) + .participants(bookingJpaEntity.getParticipants().stream().sorted(Comparator.comparing(BaseIdEntity::getId)).map(memberMapper::toDomain).collect(Collectors.toList())) .timeSlot(timeSlotMapper.toDomain(bookingJpaEntity.getTimeSlot())) .bookingDate(bookingJpaEntity.getBookingDate()) .build(); 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 81869ac..8079e97 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 @@ -39,7 +39,7 @@ public GetCurrentMemberResponse findCurrentMember() { } @Override - public void updatePassword(Long id, String currentPassword, String newPassword) { - updatePasswordUseCase.execute(id, currentPassword, newPassword); + public void updatePassword(String email, String currentPassword, String newPassword) { + updatePasswordUseCase.execute(email, currentPassword, newPassword); } } diff --git a/src/main/java/com/ampersand/groom/domain/member/application/port/MemberApplicationPort.java b/src/main/java/com/ampersand/groom/domain/member/application/port/MemberApplicationPort.java index 1a73c10..3eb4b4d 100644 --- a/src/main/java/com/ampersand/groom/domain/member/application/port/MemberApplicationPort.java +++ b/src/main/java/com/ampersand/groom/domain/member/application/port/MemberApplicationPort.java @@ -13,5 +13,5 @@ public interface MemberApplicationPort { GetCurrentMemberResponse findCurrentMember(); - void updatePassword(Long id, String currentPassword, String newPassword); + void updatePassword(String email, String currentPassword, String newPassword); } \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/domain/member/application/port/MemberPersistencePort.java b/src/main/java/com/ampersand/groom/domain/member/application/port/MemberPersistencePort.java index f962c66..b7dfddf 100644 --- a/src/main/java/com/ampersand/groom/domain/member/application/port/MemberPersistencePort.java +++ b/src/main/java/com/ampersand/groom/domain/member/application/port/MemberPersistencePort.java @@ -16,5 +16,9 @@ public interface MemberPersistencePort { List findMembersByIds(List ids); + Boolean existsMemberByEmail(String email); + void updateMemberPassword(Long id, String newPassword); + + void saveMember(Member member); } \ 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 index 3c8f745..c329da4 100644 --- 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 @@ -17,8 +17,8 @@ public class UpdatePasswordUseCase { private final MemberPersistencePort memberPersistencePort; private final BCryptPasswordEncoder bCryptPasswordEncoder; - public void execute(Long id, String currentPassword, String newPassword) { - Member member = memberPersistencePort.findMemberById(id); + public void execute(String email, String currentPassword, String newPassword) { + Member member = memberPersistencePort.findMemberByEmail(email); if (!authenticationPersistencePort.existsAuthenticationByEmail(member.getEmail()) || !authenticationPersistencePort.findAuthenticationByEmail(member.getEmail()).getVerified()) { throw new UserForbiddenException(); diff --git a/src/main/java/com/ampersand/groom/domain/member/persistence/MemberPersistenceAdapter.java b/src/main/java/com/ampersand/groom/domain/member/persistence/MemberPersistenceAdapter.java index 0ce2d9f..24b1eb6 100644 --- a/src/main/java/com/ampersand/groom/domain/member/persistence/MemberPersistenceAdapter.java +++ b/src/main/java/com/ampersand/groom/domain/member/persistence/MemberPersistenceAdapter.java @@ -73,6 +73,16 @@ public List findMembersByIds(List ids) { .toList(); } + @Override + public Boolean existsMemberByEmail(String email) { + var result = queryFactory + .selectOne() + .from(memberJpaEntity) + .where(memberJpaEntity.email.eq(email)) + .fetchFirst(); + return result != null; + } + @Override public void updateMemberPassword(Long id, String newPassword) { queryFactory @@ -81,4 +91,9 @@ public void updateMemberPassword(Long id, String newPassword) { .where(memberJpaEntity.id.eq(id)) .execute(); } + + @Override + public void saveMember(Member member) { + memberJpaRepository.save(memberMapper.toEntity(member)); + } } \ 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 55a09a3..d77ba03 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 @@ -42,13 +42,13 @@ public ResponseEntity getCurrentMember() { return ResponseEntity.status(HttpStatus.OK).body(memberApplicationPort.findCurrentMember()); } - @PatchMapping("/{memberId}/password") + @PatchMapping("/{email}/password") @ResponseStatus(HttpStatus.NO_CONTENT) public ResponseEntity updateMemberPassword( - @PathVariable(value = "memberId") Long memberId, + @PathVariable(value = "email") String email, @Valid @RequestBody UpdateMemberPasswordRequest request ) { - memberApplicationPort.updatePassword(memberId, request.currentPassword(), request.newPassword()); + memberApplicationPort.updatePassword(email, 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/global/annotation/usecase/UseCase.java b/src/main/java/com/ampersand/groom/global/annotation/usecase/UseCase.java index a0ec671..eeb57a1 100644 --- a/src/main/java/com/ampersand/groom/global/annotation/usecase/UseCase.java +++ b/src/main/java/com/ampersand/groom/global/annotation/usecase/UseCase.java @@ -8,7 +8,7 @@ import java.lang.annotation.Target; @Retention(RetentionPolicy.RUNTIME) -@Target({ElementType.ANNOTATION_TYPE}) +@Target({ElementType.TYPE, ElementType.ANNOTATION_TYPE}) @Component public @interface UseCase { } \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/config/AsyncConfig.java b/src/main/java/com/ampersand/groom/global/config/AsyncConfig.java new file mode 100644 index 0000000..bcf81cc --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/config/AsyncConfig.java @@ -0,0 +1,9 @@ +package com.ampersand.groom.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableAsync; + +@Configuration +@EnableAsync +public class AsyncConfig { +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/config/PasswordEncoderConfig.java b/src/main/java/com/ampersand/groom/global/config/PasswordEncoderConfig.java new file mode 100644 index 0000000..f2b7c02 --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/config/PasswordEncoderConfig.java @@ -0,0 +1,13 @@ +package com.ampersand.groom.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + @Bean + public BCryptPasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/error/handler/GlobalExceptionHandler.java b/src/main/java/com/ampersand/groom/global/error/handler/GlobalExceptionHandler.java index f04e32d..0379e34 100644 --- a/src/main/java/com/ampersand/groom/global/error/handler/GlobalExceptionHandler.java +++ b/src/main/java/com/ampersand/groom/global/error/handler/GlobalExceptionHandler.java @@ -2,6 +2,7 @@ import com.ampersand.groom.global.error.data.response.ErrorResponse; import com.ampersand.groom.global.error.exception.GroomException; +import jakarta.mail.MessagingException; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; @@ -15,4 +16,11 @@ public ResponseEntity handleGroomException(GroomException e) { .status(e.getErrorCode().getHttpStatus()) .body(new ErrorResponse(e.getErrorCode().getMessage(), e.getErrorCode().getHttpStatus())); } + + @ExceptionHandler(MessagingException.class) + public ResponseEntity handleMessagingException(MessagingException e) { + return ResponseEntity + .status(500) + .body(new ErrorResponse("Failed to send email.", 500)); + } } \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/security/config/DomainAuthorizationConfig.java b/src/main/java/com/ampersand/groom/global/security/config/DomainAuthorizationConfig.java new file mode 100644 index 0000000..bcff74e --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/security/config/DomainAuthorizationConfig.java @@ -0,0 +1,18 @@ +package com.ampersand.groom.global.security.config; + +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.stereotype.Component; + +@Component +public class DomainAuthorizationConfig { + + public void configureAuthorization(HttpSecurity http) throws Exception { + http.authorizeHttpRequests(auth -> auth + .requestMatchers("/api/v1/auth/**").permitAll() + .requestMatchers("/api/v1/members/{memberId}/password").permitAll() + .requestMatchers("/api/v1/**").hasAnyAuthority("ROLE_STUDENT", "ROLE_TEACHER", "ROLE_ADMIN") + .requestMatchers("/health").permitAll() + .anyRequest().permitAll() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/security/config/JwtConfig.java b/src/main/java/com/ampersand/groom/global/security/config/JwtConfig.java deleted file mode 100644 index 530e3f9..0000000 --- a/src/main/java/com/ampersand/groom/global/security/config/JwtConfig.java +++ /dev/null @@ -1,40 +0,0 @@ -package com.ampersand.groom.global.security.config; - - -import io.jsonwebtoken.SignatureAlgorithm; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; - -import javax.crypto.SecretKey; -import javax.crypto.spec.SecretKeySpec; -import java.nio.charset.StandardCharsets; - -@Configuration -public class JwtConfig { - - @Value("${spring.jwt.secret}") - private String secretKey; - - @Value("${spring.jwt.token.access-expiration}") - private long accessTokenExpiration; - - @Value("${spring.jwt.token.refresh-expiration}") - private long refreshTokenExpiration; - - @Bean - public SecretKey secretKey() { - return new SecretKeySpec(secretKey.getBytes(StandardCharsets.UTF_8), SignatureAlgorithm.HS256.getJcaName()); - } - - @Bean - public long accessTokenExpiration() { - return accessTokenExpiration; - } - - @Bean - public long refreshTokenExpiration() { - return refreshTokenExpiration; - } - -} 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 ad10f25..f130190 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 @@ -1,9 +1,7 @@ package com.ampersand.groom.global.security.config; -import com.ampersand.groom.domain.auth.application.service.CustomUserDetailsService; -import com.ampersand.groom.domain.auth.application.service.JwtService; -import com.ampersand.groom.domain.member.domain.constant.MemberRole; -import com.ampersand.groom.global.error.exception.GroomException; +import com.ampersand.groom.global.security.jwt.filter.JwtAuthenticationFilter; +import com.ampersand.groom.global.security.jwt.service.JwtParserService; import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,7 +9,6 @@ import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; -import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @@ -20,24 +17,16 @@ @RequiredArgsConstructor public class SecurityConfig { - private final JwtService jwtService; - private final CustomUserDetailsService customUserDetailsService; - + private final JwtParserService jwtParserService; + private final DomainAuthorizationConfig domainAuthorizationConfig; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + domainAuthorizationConfig.configureAuthorization(http); http .cors(AbstractHttpConfigurer::disable) .csrf(AbstractHttpConfigurer::disable) - .addFilterBefore(jwtAuthenticationFilter(), UsernamePasswordAuthenticationFilter.class) - .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() - ) + .addFilterBefore(new JwtAuthenticationFilter(jwtParserService), UsernamePasswordAuthenticationFilter.class) .formLogin(AbstractHttpConfigurer::disable) .httpBasic(AbstractHttpConfigurer::disable) .sessionManagement(sessionManagement -> @@ -46,14 +35,4 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti ); return http.build(); } - - @Bean - public JwtAuthenticationFilter jwtAuthenticationFilter() throws GroomException { - return new JwtAuthenticationFilter(jwtService, customUserDetailsService); - } - - @Bean - public BCryptPasswordEncoder passwordEncoder() { - return new BCryptPasswordEncoder(); - } } \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/security/jwt/data/TokenDto.java b/src/main/java/com/ampersand/groom/global/security/jwt/data/TokenDto.java new file mode 100644 index 0000000..a67670c --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/security/jwt/data/TokenDto.java @@ -0,0 +1,9 @@ +package com.ampersand.groom.global.security.jwt.data; + +import java.time.LocalDateTime; + +public record TokenDto( + String token, + LocalDateTime expiration +) { +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/security/jwt/entity/RefreshTokenRedisEntity.java b/src/main/java/com/ampersand/groom/global/security/jwt/entity/RefreshTokenRedisEntity.java new file mode 100644 index 0000000..99633a8 --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/security/jwt/entity/RefreshTokenRedisEntity.java @@ -0,0 +1,30 @@ +package com.ampersand.groom.global.security.jwt.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("refresh_token") +@Getter +@NoArgsConstructor +public class RefreshTokenRedisEntity { + @Id + private String token; + @Indexed + private String email; + @TimeToLive(unit = TimeUnit.SECONDS) + private Long expiration; + + @Builder + public RefreshTokenRedisEntity(String token, String email, Long expiration) { + this.token = token; + this.email = email; + this.expiration = expiration; + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/security/config/JwtAuthenticationFilter.java b/src/main/java/com/ampersand/groom/global/security/jwt/filter/JwtAuthenticationFilter.java similarity index 67% rename from src/main/java/com/ampersand/groom/global/security/config/JwtAuthenticationFilter.java rename to src/main/java/com/ampersand/groom/global/security/jwt/filter/JwtAuthenticationFilter.java index 0623870..a53a593 100644 --- a/src/main/java/com/ampersand/groom/global/security/config/JwtAuthenticationFilter.java +++ b/src/main/java/com/ampersand/groom/global/security/jwt/filter/JwtAuthenticationFilter.java @@ -1,9 +1,7 @@ -package com.ampersand.groom.global.security.config; +package com.ampersand.groom.global.security.jwt.filter; -import com.ampersand.groom.domain.auth.application.service.CustomUserDetailsService; -import com.ampersand.groom.domain.auth.application.service.JwtService; -import com.ampersand.groom.domain.auth.exception.UserNotFoundException; import com.ampersand.groom.domain.member.domain.constant.MemberRole; +import com.ampersand.groom.global.security.jwt.service.JwtParserService; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -22,36 +20,32 @@ @RequiredArgsConstructor public class JwtAuthenticationFilter extends OncePerRequestFilter { - private final JwtService jwtService; - private final CustomUserDetailsService customUserDetailsService; - private static final AntPathMatcher pathMatcher = new AntPathMatcher(); + private final JwtParserService jwtParserService; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - - String token = jwtService.resolveToken(request); - + String token = jwtParserService.resolveToken(request); String uri = request.getRequestURI(); - - if (uri.startsWith("/auth") + if (uri.startsWith("/api/v1/auth") || uri.equals("/health") || pathMatcher.match("/api/v1/members/**/password", uri)) { filterChain.doFilter(request, response); return; } - if (token != null && jwtService.validateToken(token)) { - String email = jwtService.getEmailFromToken(token); - MemberRole roles = jwtService.getRoleFromToken(token); + if (token != null && jwtParserService.validateAccessToken(token)) { + String email = jwtParserService.getEmailFromAccessToken(token); + MemberRole roles = jwtParserService.getRolesFromAccessToken(token); List authorities = List.of(new SimpleGrantedAuthority(roles.name())); UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(email, null, authorities); - SecurityContextHolder.getContext().setAuthentication(authentication); + filterChain.doFilter(request, response); + return; } - filterChain.doFilter(request, response); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write("{\"message\": \"Unauthorized or invalid token.\"}"); } - -} - +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/security/jwt/repository/RefreshTokenRedisRepository.java b/src/main/java/com/ampersand/groom/global/security/jwt/repository/RefreshTokenRedisRepository.java new file mode 100644 index 0000000..0fc7933 --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/security/jwt/repository/RefreshTokenRedisRepository.java @@ -0,0 +1,9 @@ +package com.ampersand.groom.global.security.jwt.repository; + +import com.ampersand.groom.global.security.jwt.entity.RefreshTokenRedisEntity; +import org.springframework.data.repository.CrudRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RefreshTokenRedisRepository extends CrudRepository { +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/security/jwt/service/JwtIssueService.java b/src/main/java/com/ampersand/groom/global/security/jwt/service/JwtIssueService.java new file mode 100644 index 0000000..521b833 --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/security/jwt/service/JwtIssueService.java @@ -0,0 +1,10 @@ +package com.ampersand.groom.global.security.jwt.service; + +import com.ampersand.groom.domain.member.domain.constant.MemberRole; +import com.ampersand.groom.global.security.jwt.data.TokenDto; + +public interface JwtIssueService { + TokenDto issueAccessToken(String email, MemberRole roles); + + TokenDto issueRefreshToken(String email); +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/security/jwt/service/JwtParserService.java b/src/main/java/com/ampersand/groom/global/security/jwt/service/JwtParserService.java new file mode 100644 index 0000000..b02b263 --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/security/jwt/service/JwtParserService.java @@ -0,0 +1,18 @@ +package com.ampersand.groom.global.security.jwt.service; + +import com.ampersand.groom.domain.member.domain.constant.MemberRole; +import jakarta.servlet.http.HttpServletRequest; + +public interface JwtParserService { + String getEmailFromAccessToken(String token); + + MemberRole getRolesFromAccessToken(String token); + + String getEmailFromRefreshToken(String token); + + Boolean validateAccessToken(String token); + + Boolean validateRefreshToken(String token); + + String resolveToken(HttpServletRequest request); +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/security/jwt/service/JwtRefreshManagementService.java b/src/main/java/com/ampersand/groom/global/security/jwt/service/JwtRefreshManagementService.java new file mode 100644 index 0000000..00c24d6 --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/security/jwt/service/JwtRefreshManagementService.java @@ -0,0 +1,5 @@ +package com.ampersand.groom.global.security.jwt.service; + +public interface JwtRefreshManagementService { + void deleteRefreshToken(String token); +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/security/jwt/service/impl/JwtIssueServiceImpl.java b/src/main/java/com/ampersand/groom/global/security/jwt/service/impl/JwtIssueServiceImpl.java new file mode 100644 index 0000000..e526d48 --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/security/jwt/service/impl/JwtIssueServiceImpl.java @@ -0,0 +1,79 @@ +package com.ampersand.groom.global.security.jwt.service.impl; + +import com.ampersand.groom.domain.member.domain.constant.MemberRole; +import com.ampersand.groom.global.security.jwt.data.TokenDto; +import com.ampersand.groom.global.security.jwt.entity.RefreshTokenRedisEntity; +import com.ampersand.groom.global.security.jwt.repository.RefreshTokenRedisRepository; +import com.ampersand.groom.global.security.jwt.service.JwtIssueService; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class JwtIssueServiceImpl implements JwtIssueService { + + private final RefreshTokenRedisRepository refreshTokenRedisRepository; + @Value("${spring.jwt.access-token.secret}") + private String accessTokenSecret; + @Value("${spring.jwt.access-token.expiration}") + private long accessTokenExpiration; + @Value("${spring.jwt.refresh-token.secret}") + private String refreshTokenSecret; + @Value("${spring.jwt.refresh-token.expiration}") + private long refreshTokenExpiration; + private SecretKey accessTokenKey; + private SecretKey refreshTokenKey; + + @PostConstruct + public void init() { + accessTokenKey = Keys.hmacShaKeyFor(accessTokenSecret.getBytes()); + refreshTokenKey = Keys.hmacShaKeyFor(refreshTokenSecret.getBytes()); + } + + @Override + public TokenDto issueAccessToken(String email, MemberRole roles) { + LocalDateTime expiration = LocalDateTime.now().plusSeconds(accessTokenExpiration); + return new TokenDto( + Jwts.builder() + .claim("sub", email) + .claim("role", roles) + .claim("iat", LocalDateTime.now().atZone(ZoneId.of("Asia/Seoul")).toEpochSecond()) + .claim("exp", expiration.atZone(ZoneId.of("Asia/Seoul")).toEpochSecond()) + .claim("jti", UUID.randomUUID().toString()) + .signWith(accessTokenKey) + .compact(), + expiration + ); + } + + @Override + public TokenDto issueRefreshToken(String email) { + LocalDateTime expiration = LocalDateTime.now().plusSeconds(refreshTokenExpiration); + TokenDto token = new TokenDto( + Jwts.builder() + .claim("sub", email) + .claim("iat", LocalDateTime.now().atZone(ZoneId.of("Asia/Seoul")).toEpochSecond()) + .claim("exp", expiration.atZone(ZoneId.of("Asia/Seoul")).toEpochSecond()) + .claim("jti", UUID.randomUUID().toString()) + .signWith(refreshTokenKey) + .compact(), + expiration + ); + refreshTokenRedisRepository.save(RefreshTokenRedisEntity.builder() + .token(token.token()) + .email(email) + .expiration((long) expiration.getSecond()) + .build() + ); + return token; + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/security/jwt/service/impl/JwtParserServiceImpl.java b/src/main/java/com/ampersand/groom/global/security/jwt/service/impl/JwtParserServiceImpl.java new file mode 100644 index 0000000..f69f07a --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/security/jwt/service/impl/JwtParserServiceImpl.java @@ -0,0 +1,89 @@ +package com.ampersand.groom.global.security.jwt.service.impl; + +import com.ampersand.groom.domain.member.domain.constant.MemberRole; +import com.ampersand.groom.global.security.jwt.service.JwtParserService; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.security.Keys; +import jakarta.annotation.PostConstruct; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; + +@Service +@RequiredArgsConstructor +public class JwtParserServiceImpl implements JwtParserService { + + @Value("${spring.jwt.access-token.secret}") + private String accessTokenSecret; + @Value("${spring.jwt.refresh-token.secret}") + private String refreshTokenSecret; + private SecretKey accessTokenKey; + private SecretKey refreshTokenKey; + + @PostConstruct + public void init() { + accessTokenKey = Keys.hmacShaKeyFor(accessTokenSecret.getBytes()); + refreshTokenKey = Keys.hmacShaKeyFor(refreshTokenSecret.getBytes()); + } + + @Override + public Boolean validateAccessToken(String token) { + try { + parseAccessTokenClaims(token); + return true; + } catch (Exception e) { + return false; + } + } + + @Override + public Boolean validateRefreshToken(String token) { + try { + parseRefreshTokenClaims(token); + return true; + } catch (Exception e) { + return false; + } + } + + @Override + public String getEmailFromAccessToken(String token) { + return parseAccessTokenClaims(token).getSubject(); + } + + @Override + public MemberRole getRolesFromAccessToken(String token) { + return MemberRole.valueOf(parseAccessTokenClaims(token).get("role", String.class)); + } + + @Override + public String getEmailFromRefreshToken(String token) { + return parseRefreshTokenClaims(token).getSubject(); + } + + @Override + public String resolveToken(HttpServletRequest request) { + String token = request.getHeader("Authorization"); + return (token != null && token.startsWith("Bearer ")) ? token.substring(7) : null; + } + + private Claims parseAccessTokenClaims(String token) { + return Jwts.parser() + .verifyWith(accessTokenKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + private Claims parseRefreshTokenClaims(String token) { + return Jwts.parser() + .verifyWith(refreshTokenKey) + .build() + .parseSignedClaims(token) + .getPayload(); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/security/jwt/service/impl/JwtRefreshManagementServiceImpl.java b/src/main/java/com/ampersand/groom/global/security/jwt/service/impl/JwtRefreshManagementServiceImpl.java new file mode 100644 index 0000000..a879e0b --- /dev/null +++ b/src/main/java/com/ampersand/groom/global/security/jwt/service/impl/JwtRefreshManagementServiceImpl.java @@ -0,0 +1,18 @@ +package com.ampersand.groom.global.security.jwt.service.impl; + +import com.ampersand.groom.global.security.jwt.repository.RefreshTokenRedisRepository; +import com.ampersand.groom.global.security.jwt.service.JwtRefreshManagementService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class JwtRefreshManagementServiceImpl implements JwtRefreshManagementService { + + private final RefreshTokenRedisRepository refreshTokenRedisRepository; + + @Override + public void deleteRefreshToken(String token) { + refreshTokenRedisRepository.deleteById(token); + } +} \ No newline at end of file diff --git a/src/main/java/com/ampersand/groom/global/thirdParty/EmailConfig.java b/src/main/java/com/ampersand/groom/infrastructure/thirdparty/email/config/EmailConfig.java similarity index 95% rename from src/main/java/com/ampersand/groom/global/thirdParty/EmailConfig.java rename to src/main/java/com/ampersand/groom/infrastructure/thirdparty/email/config/EmailConfig.java index b6c80da..0dfde6f 100644 --- a/src/main/java/com/ampersand/groom/global/thirdParty/EmailConfig.java +++ b/src/main/java/com/ampersand/groom/infrastructure/thirdparty/email/config/EmailConfig.java @@ -1,4 +1,4 @@ -package com.ampersand.groom.global.thirdParty; +package com.ampersand.groom.infrastructure.thirdparty.email.config; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; diff --git a/src/main/java/com/ampersand/groom/infrastructure/thirdparty/email/service/EmailSendService.java b/src/main/java/com/ampersand/groom/infrastructure/thirdparty/email/service/EmailSendService.java new file mode 100644 index 0000000..fc6a4df --- /dev/null +++ b/src/main/java/com/ampersand/groom/infrastructure/thirdparty/email/service/EmailSendService.java @@ -0,0 +1,41 @@ +package com.ampersand.groom.infrastructure.thirdparty.email.service; + +import jakarta.mail.MessagingException; +import jakarta.mail.internet.MimeMessage; +import lombok.RequiredArgsConstructor; +import org.springframework.core.io.FileSystemResource; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.thymeleaf.context.Context; +import org.thymeleaf.spring6.SpringTemplateEngine; + +import java.io.File; + +@Service +@RequiredArgsConstructor +public class EmailSendService { + + private static final String EMAIL_SUBJECT = "Groom 이메일 인증"; + private final JavaMailSender mailSender; + private final SpringTemplateEngine templateEngine; + + @Async + public void sendMail(String to, String authCode) throws MessagingException { + Context context = new Context(); + context.setVariable("authCode", authCode); + context.setVariable("logoCid", "groomLogo"); + String html = templateEngine.process("MailTemplate", context); + MimeMessage message = mailSender.createMimeMessage(); + MimeMessageHelper helper = new MimeMessageHelper(message, true, "UTF-8"); + helper.setTo(to); + helper.setSubject(EMAIL_SUBJECT); + helper.setText(html, true); + FileSystemResource logoImage = new FileSystemResource( + new File("src/main/resources/static/images/groom-logo.png") + ); + helper.addInline("groomLogo", logoImage); + mailSender.send(message); + } +} \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index dafc8e0..8bd1468 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -13,4 +13,26 @@ spring: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD} \ No newline at end of file + password: ${REDIS_PASSWORD} + jwt: + access-token: + secret: dev-jwt-access-token-secret-key + expiration: 7200 + refresh-token: + secret: dev-jwt-refresh-token-secret-key + expiration: 7200 +email: + host: ${EMAIL_HOST} + port: 587 + username: ${EMAIL_USERNAME} + password: ${EMAIL_PASSWORD} + authentication: + object: + ttl: ${EMAIL_AUTHENTICATION_OBJECT_TTL:300} + attempt-count-limit: ${EMAIL_AUTHENTICATION_OBJECT_ATTEMPT_COUNT_LIMIT:5} + properties: + mail: + smtp: + auth: true + starttls: + enable: true \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 142f08b..20ae10d 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -14,15 +14,21 @@ spring: host: localhost port: 6379 jwt: - secret: test-jwt-secret-key - token: - access-expiration: 7200000 - refresh-expiration: 2592000000 + access-token: + secret: test-jwt-access-token-secret-key + expiration: 7200 + refresh-token: + secret: test-jwt-refresh-token-secret-key + expiration: 2592000 email: host: smtp.example.com port: 587 username: test-email@example.com password: test-password + authentication: + object: + ttl: 300 + attempt-count-limit: 5 properties: mail: smtp: diff --git a/src/main/resources/static/images/groom-logo.png b/src/main/resources/static/images/groom-logo.png new file mode 100644 index 0000000..dd974a7 Binary files /dev/null and b/src/main/resources/static/images/groom-logo.png differ diff --git a/src/main/resources/static/images/groom-logo.svg b/src/main/resources/static/images/groom-logo.svg new file mode 100644 index 0000000..5a55e4d --- /dev/null +++ b/src/main/resources/static/images/groom-logo.svg @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/MailTemplate.html b/src/main/resources/templates/MailTemplate.html new file mode 100644 index 0000000..11de8af --- /dev/null +++ b/src/main/resources/templates/MailTemplate.html @@ -0,0 +1,92 @@ + + + + + Groom 이메일 인증 + + + + +
+ +

이메일 인증 안내

+

+ 안녕하세요! Groom 서비스를 이용해주셔서 감사합니다.
+ 요청하신 작업을 진행하기 위해 이메일 인증이 필요합니다.
+ 아래 인증 코드를 입력해 주세요. +

+
+ [[${authCode}]] +
+ +
+ + \ No newline at end of file diff --git a/src/test/java/com/ampersand/groom/domain/auth/application/AuthServiceTest.java b/src/test/java/com/ampersand/groom/domain/auth/application/AuthServiceTest.java deleted file mode 100644 index fc160e6..0000000 --- a/src/test/java/com/ampersand/groom/domain/auth/application/AuthServiceTest.java +++ /dev/null @@ -1,192 +0,0 @@ -package com.ampersand.groom.domain.auth.application; - -import com.ampersand.groom.domain.auth.application.service.AuthService; -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; -import com.ampersand.groom.domain.member.domain.constant.MemberRole; -import org.junit.jupiter.api.*; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import java.time.Instant; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -@DisplayName("AuthService 클래스의") -class AuthServiceTest { - - @Mock - private AuthService authService; - - @DisplayName("signIn 메서드는") - @Nested - class Describe_signIn { - - @Nested - @DisplayName("정상적인 로그인 시") - class Context_with_valid_credentials { - - @Test - @DisplayName("JWT 토큰을 반환한다.") - void it_returns_jwt_token() { - // given - String email = "test@example.com"; - String password = "password123"; - JwtToken validToken = JwtToken.builder() - .accessToken("validAccessToken") - .refreshToken("validRefreshToken") - .accessTokenExpiration(Instant.now().plusMillis(1000)) - .refreshTokenExpiration(Instant.now().plusMillis(1000)) - .role(MemberRole.ROLE_STUDENT) - .build(); - when(authService.signIn(email, password)).thenReturn(validToken); - - // when - JwtToken result = authService.signIn(email, password); - - // then - verify(authService).signIn(email, password); - assertNotNull(result); - assertEquals("validAccessToken", result.getAccessToken()); - } - } - - @Nested - @DisplayName("잘못된 로그인 시도 시") - class Context_with_invalid_credentials { - - @Test - @DisplayName("PasswordInvalidException을 던진다.") - void it_throws_password_invalid_exception() { - // given - String email = "test@example.com"; - String password = "wrongPassword"; - when(authService.signIn(email, password)).thenThrow(new PasswordInvalidException()); - - // when & then - assertThrows(PasswordInvalidException.class, () -> authService.signIn(email, password)); - } - - @Test - @DisplayName("UserNotFoundException을 던진다.") - void it_throws_user_not_found_exception() { - // given - String email = "unknown@example.com"; - String password = "password123"; - when(authService.signIn(email, password)).thenThrow(new UserNotFoundException()); - - // when & then - assertThrows(UserNotFoundException.class, () -> authService.signIn(email, password)); - } - } - } - - @DisplayName("signup 메서드는") - @Nested - class Describe_signup { - - @Nested - @DisplayName("정상적인 회원가입 시") - class Context_with_valid_signup { - - @Test - @DisplayName("회원가입을 성공적으로 수행한다.") - void it_signs_up_successfully() { - // given - SignupRequest signupRequest = new SignupRequest("test@example.com", "password123", "John Doe"); - doNothing().when(authService).signup(signupRequest); - - // when - assertDoesNotThrow(() -> authService.signup(signupRequest)); - - // then - verify(authService).signup(signupRequest); - } - } - - @Nested - @DisplayName("중복된 이메일로 회원가입 시") - class Context_with_existing_email { - - @Test - @DisplayName("UserExistException을 던진다.") - void it_throws_user_exist_exception() { - // given - SignupRequest signupRequest = new SignupRequest("test@example.com", "password123", "John Doe"); - doThrow(new UserExistException()).when(authService).signup(signupRequest); - - // when & then - assertThrows(UserExistException.class, () -> authService.signup(signupRequest)); - } - } - } - - @DisplayName("refreshToken 메서드는") - @Nested - class Describe_refreshToken { - - @Nested - @DisplayName("정상적인 리프레시 토큰") - class Context_with_valid_refresh_token { - - @Test - @DisplayName("새로운 JWT 토큰을 반환한다.") - void it_returns_new_jwt_token() { - // given - String refreshToken = "validRefreshToken"; - JwtToken validToken = JwtToken.builder() - .accessToken("newAccessToken") - .refreshToken("newRefreshToken") - .accessTokenExpiration(Instant.now().plusMillis(1000)) - .refreshTokenExpiration(Instant.now().plusMillis(1000)) - .role(MemberRole.ROLE_STUDENT) - .build(); - when(authService.refreshToken(refreshToken)).thenReturn(validToken); - - // when - JwtToken result = authService.refreshToken(refreshToken); - - // then - verify(authService).refreshToken(refreshToken); - assertNotNull(result); - assertEquals("newAccessToken", result.getAccessToken()); - } - } - - @Nested - @DisplayName("잘못된 리프레시 토큰") - class Context_with_invalid_refresh_token { - - @Test - @DisplayName("RefreshTokenExpiredOrInvalidException을 던진다.") - void it_throws_refresh_token_expired_or_invalid_exception() { - // given - String refreshToken = "invalidRefreshToken"; - when(authService.refreshToken(refreshToken)).thenThrow(new RefreshTokenExpiredOrInvalidException()); - - // when & then - assertThrows(RefreshTokenExpiredOrInvalidException.class, () -> authService.refreshToken(refreshToken)); - } - } - - @Nested - @DisplayName("리프레시 토큰이 없거나 비어있는 경우") - class Context_with_empty_refresh_token { - - @Test - @DisplayName("RefreshTokenRequestFormatInvalidException을 던진다.") - void it_throws_refresh_token_request_format_invalid_exception() { - // given - String refreshToken = ""; - when(authService.refreshToken(refreshToken)).thenThrow(new RefreshTokenRequestFormatInvalidException()); - - // when & then - assertThrows(RefreshTokenRequestFormatInvalidException.class, () -> authService.refreshToken(refreshToken)); - } - } - } -} diff --git a/src/test/java/com/ampersand/groom/domain/auth/application/usecase/EmailVerificationUseCaseTest.java b/src/test/java/com/ampersand/groom/domain/auth/application/usecase/EmailVerificationUseCaseTest.java deleted file mode 100644 index 96a9cdb..0000000 --- a/src/test/java/com/ampersand/groom/domain/auth/application/usecase/EmailVerificationUseCaseTest.java +++ /dev/null @@ -1,173 +0,0 @@ -package com.ampersand.groom.domain.auth.application.usecase; - -import com.ampersand.groom.domain.auth.application.service.EmailVerificationService; -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 org.junit.jupiter.api.*; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.Mockito.*; - -@ExtendWith(MockitoExtension.class) -@DisplayName("EmailVerificationUseCase 클래스의") -class EmailVerificationUseCaseTest { - - @Mock - private EmailVerificationService emailVerificationService; - - @InjectMocks - private EmailVerificationUseCase emailVerificationUseCase; - - @DisplayName("executeSendSignupVerificationEmail 메서드는") - @Nested - class Describe_executeSendSignupVerificationEmail { - - @Nested - @DisplayName("유효한 이메일을 입력했을 때") - class Context_with_valid_email { - - @Test - @DisplayName("회원가입 인증 이메일을 전송한다.") - void it_sends_signup_verification_email() { - // given - String email = "test@example.com"; - - // when - emailVerificationUseCase.executeSendSignupVerificationEmail(email); - - // then - verify(emailVerificationService).sendSignupVerificationEmail(email); // 메서드 호출 검증 - } - } - - @Nested - @DisplayName("유효하지 않은 이메일을 입력했을 때") - class Context_with_invalid_email { - - @Test - @DisplayName("InvalidFormatException을 발생시킨다.") - void it_throws_invalid_format_exception() { - // given - String invalidEmail = "email"; // 잘못된 이메일 주소 - - - // when - doThrow(new EmailFormatInvalidException()) - .when(emailVerificationService).sendSignupVerificationEmail(invalidEmail); - - - EmailFormatInvalidException exception = assertThrows(EmailFormatInvalidException.class, () -> { - emailVerificationUseCase.executeSendSignupVerificationEmail(invalidEmail); // 이메일 전송 시 예외 발생해야 함 - }); - - // then - assertEquals("Email format invalid", exception.getErrorCode().getMessage()); // 예외 메시지 확인 - assertEquals(400, exception.getErrorCode().getHttpStatus()); // HTTP 상태 코드 확인 - } - - } - - @DisplayName("executeSendPasswordResetEmail 메서드는") - @Nested - class Describe_executeSendPasswordResetEmail { - - @Nested - @DisplayName("유효한 이메일을 입력했을 때") - class Context_with_valid_email { - - @Test - @DisplayName("비밀번호 변경 인증 이메일을 전송한다.") - void it_sends_password_reset_email() { - // given - String email = "s24010@gsm.hs.kr"; - - // when - emailVerificationUseCase.executeSendPasswordResetEmail(email); - - // then - verify(emailVerificationService).sendPasswordResetEmail(email); // 메서드 호출 검증 - } - } - - @Nested - @DisplayName("유효하지 않은 이메일을 입력했을 때") - class Context_with_invalid_email { - - @Test - @DisplayName("InvalidFormatException을 발생시킨다.") - void it_throws_invalid_format_exception() { - // given - String invalidEmail = "email"; // 잘못된 이메일 주소 - - // when - doThrow(new EmailFormatInvalidException()) - .when(emailVerificationService).sendPasswordResetEmail(invalidEmail); - - EmailFormatInvalidException exception = assertThrows(EmailFormatInvalidException.class, () -> { - emailVerificationUseCase.executeSendPasswordResetEmail(invalidEmail); // 이메일 전송 시 예외 발생해야 함 - }); - - // then - assertEquals("Email format invalid", exception.getErrorCode().getMessage()); - assertEquals(400, exception.getErrorCode().getHttpStatus()); - } - - } - } - - @DisplayName("executeVerifyCode 메서드는") - @Nested - class Describe_executeVerifyCode { - - @Nested - @DisplayName("유효하지 않거나 만료된 인증 코드를 입력했을 때") - class Context_with_invalid_or_expired_code { - - @Test - @DisplayName("InvalidOrExpiredCodeException을 발생시킨다.") - void it_throws_invalid_or_expired_code_exception() { - // given - String invalidCode = "12345678"; - - // when - doThrow(new VerificationCodeExpiredOrInvalidException()) - .when(emailVerificationService).verifyCode(invalidCode); // doThrow 사용 - - // then - VerificationCodeExpiredOrInvalidException exception = assertThrows(VerificationCodeExpiredOrInvalidException.class, () -> { - emailVerificationUseCase.executeVerifyCode(invalidCode); - }); - - assertEquals("Verification code expired or invalid", exception.getErrorCode().getMessage()); - assertEquals(401, exception.getErrorCode().getHttpStatus()); - } - } - - @Nested - @DisplayName("올바르지 않은 형식의 코드를 입력했을 때") - class Context_with_invalid_code_format { - - @Test - @DisplayName("InvalidFormatException을 발생시킨다.") - void it_throws_invalid_format_exception() { - String invalidCode = "123456"; - - doThrow(new VerificationCodeFormatInvalidException()) - .when(emailVerificationService).verifyCode(invalidCode); - - VerificationCodeFormatInvalidException exception = assertThrows(VerificationCodeFormatInvalidException.class, () -> { - emailVerificationUseCase.executeVerifyCode(invalidCode); - }); - - assertEquals("Verification code format invalid", exception.getErrorCode().getMessage()); - assertEquals(400, exception.getErrorCode().getHttpStatus()); - } - } - } - } -} diff --git a/src/test/java/com/ampersand/groom/domain/auth/application/usecase/RefreshUseCaseTest.java b/src/test/java/com/ampersand/groom/domain/auth/application/usecase/RefreshUseCaseTest.java new file mode 100644 index 0000000..4048995 --- /dev/null +++ b/src/test/java/com/ampersand/groom/domain/auth/application/usecase/RefreshUseCaseTest.java @@ -0,0 +1,103 @@ +package com.ampersand.groom.domain.auth.application.usecase; + +import com.ampersand.groom.domain.auth.exception.RefreshTokenExpiredOrInvalidException; +import com.ampersand.groom.domain.auth.presentation.data.response.AuthTokenResponse; +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.global.security.jwt.data.TokenDto; +import com.ampersand.groom.global.security.jwt.service.JwtIssueService; +import com.ampersand.groom.global.security.jwt.service.JwtParserService; +import com.ampersand.groom.global.security.jwt.service.JwtRefreshManagementService; +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 java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("JWT 재발급 UseCase 클래스의") +class RefreshUseCaseTest { + + @Mock + private JwtIssueService jwtIssueService; + + @Mock + private JwtParserService jwtParserService; + + @Mock + private JwtRefreshManagementService jwtRefreshManagementService; + + @Mock + private MemberPersistencePort memberPersistencePort; + + @InjectMocks + private RefreshUseCase refreshUseCase; + + @Nested + @DisplayName("execute 메서드는") + class Describe_execute { + + @Nested + @DisplayName("유효한 리프레시 토큰일 때") + class Context_with_valid_refresh_token { + + @Test + @DisplayName("새로운 액세스 토큰과 리프레시 토큰을 반환한다.") + void it_returns_new_tokens() { + String refreshToken = "valid-refresh-token"; + String email = "s00001@gsm.hs.kr"; + MemberRole role = MemberRole.ROLE_STUDENT; + Member member = Member.builder().email(email).role(role).build(); + LocalDateTime accessTokenExpiration = LocalDateTime.now().plusMinutes(30); + LocalDateTime refreshTokenExpiration = LocalDateTime.now().plusDays(7); + TokenDto newAccessToken = new TokenDto("new-access-token", accessTokenExpiration); + TokenDto newRefreshToken = new TokenDto("new-refresh-token", refreshTokenExpiration); + when(jwtParserService.validateRefreshToken(refreshToken)).thenReturn(true); + when(jwtParserService.getEmailFromRefreshToken(refreshToken)).thenReturn(email); + when(memberPersistencePort.findMemberByEmail(email)).thenReturn(member); + when(jwtIssueService.issueAccessToken(email, role)).thenReturn(newAccessToken); + when(jwtIssueService.issueRefreshToken(email)).thenReturn(newRefreshToken); + + // When + AuthTokenResponse response = refreshUseCase.execute(refreshToken); + + // Then + assertAll( + () -> assertEquals("new-access-token", response.accessToken()), + () -> assertEquals("new-refresh-token", response.refreshToken()), + () -> assertEquals(accessTokenExpiration, response.accessTokenExpiresAt()), + () -> assertEquals(refreshTokenExpiration, response.refreshTokenExpiresAt()), + () -> assertEquals(role, response.role()) + ); + verify(jwtRefreshManagementService).deleteRefreshToken(refreshToken); + } + + @Nested + @DisplayName("유효하지 않은 리프레시 토큰일 때") + class Context_with_invalid_refresh_token { + + @Test + @DisplayName("RefreshTokenExpiredOrInvalidException을 던진다.") + void it_throws_RefreshTokenExpiredOrInvalidException() { + // Given + String invalidToken = "expired-token"; + when(jwtParserService.validateRefreshToken(invalidToken)).thenReturn(false); + + // When & Then + assertThrows(RefreshTokenExpiredOrInvalidException.class, () -> + refreshUseCase.execute(invalidToken) + ); + verify(jwtRefreshManagementService, never()).deleteRefreshToken(any()); + } + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/ampersand/groom/domain/auth/application/usecase/SendAuthenticationEmailUseCaseTest.java b/src/test/java/com/ampersand/groom/domain/auth/application/usecase/SendAuthenticationEmailUseCaseTest.java new file mode 100644 index 0000000..c91d02c --- /dev/null +++ b/src/test/java/com/ampersand/groom/domain/auth/application/usecase/SendAuthenticationEmailUseCaseTest.java @@ -0,0 +1,127 @@ +package com.ampersand.groom.domain.auth.application.usecase; + +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.application.port.AuthCodePersistencePort; +import com.ampersand.groom.domain.auth.application.port.AuthenticationPersistencePort; +import com.ampersand.groom.infrastructure.thirdparty.email.service.EmailSendService; +import jakarta.mail.MessagingException; +import org.junit.jupiter.api.BeforeEach; +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.*; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("인증 이메일 전송 UseCase 클래스의") +class SendAuthenticationEmailUseCaseTest { + + @Mock + private AuthCodePersistencePort authCodePersistencePort; + + @Mock + private AuthenticationPersistencePort authenticationPersistencePort; + + @Mock + private EmailSendService emailSendService; + + @InjectMocks + private SendAuthenticationEmailUseCase sendAuthenticationEmailUseCase; + + private final String email = "s00001@email.com"; + private final long ttl = 300L; + private final int attemptLimit = 3; + + @BeforeEach + void setUp() { + ReflectionTestUtils.setField(sendAuthenticationEmailUseCase, "ttl", ttl); + ReflectionTestUtils.setField(sendAuthenticationEmailUseCase, "attemptCountLimit", attemptLimit); + } + + @Nested + @DisplayName("execute 메서드는") + class Describe_execute { + + @Nested + @DisplayName("처음 인증을 요청할 때") + class Context_with_first_auth_request { + + @Test + @DisplayName("인증 객체를 저장하고 인증 코드를 전송한다.") + void it_saves_new_authentication_and_sends_code() throws MessagingException { + // Given + when(authenticationPersistencePort.existsAuthenticationByEmail(email)).thenReturn(false); + + // When + sendAuthenticationEmailUseCase.execute(email); + + // Then + verify(authenticationPersistencePort).saveAuthentication(argThat(auth -> + auth.getEmail().equals(email) && + auth.getAttemptCount() == 1 && + Boolean.FALSE.equals(auth.getVerified()) && + auth.getTtl().equals(ttl) + )); + verify(authCodePersistencePort).saveAuthCode(any(AuthCode.class)); + verify(emailSendService).sendMail(eq(email), anyString()); + } + } + + @Nested + @DisplayName("인증 요청이 이미 존재할 때") + class Context_with_existing_authentication { + + @Test + @DisplayName("시도 횟수가 초과되지 않았다면 업데이트 후 이메일을 전송한다.") + void it_updates_authentication_and_sends_code() throws MessagingException { + // Given + Authentication existing = Authentication.builder() + .email(email) + .attemptCount(1) + .verified(false) + .ttl(ttl) + .build(); + when(authenticationPersistencePort.existsAuthenticationByEmail(email)).thenReturn(true); + when(authenticationPersistencePort.findAuthenticationByEmail(email)).thenReturn(existing); + + // When + sendAuthenticationEmailUseCase.execute(email); + + // Then + verify(authenticationPersistencePort).saveAuthentication(argThat(auth -> + auth.getAttemptCount() == 2 + )); + verify(authCodePersistencePort).saveAuthCode(any(AuthCode.class)); + verify(emailSendService).sendMail(eq(email), anyString()); + } + + @Test + @DisplayName("시도 횟수가 초과되었다면 예외를 던진다.") + void it_throws_EmailAuthRateLimitException() throws MessagingException { + // Given + Authentication rateLimited = Authentication.builder() + .email(email) + .attemptCount(attemptLimit) + .verified(false) + .ttl(ttl) + .build(); + when(authenticationPersistencePort.existsAuthenticationByEmail(email)).thenReturn(true); + when(authenticationPersistencePort.findAuthenticationByEmail(email)).thenReturn(rateLimited); + + // When & Then + assertThrows(EmailAuthRateLimitException.class, () -> + sendAuthenticationEmailUseCase.execute(email) + ); + verify(authCodePersistencePort, never()).saveAuthCode(any()); + verify(emailSendService, never()).sendMail(any(), any()); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/ampersand/groom/domain/auth/application/usecase/SignInUseCaseTest.java b/src/test/java/com/ampersand/groom/domain/auth/application/usecase/SignInUseCaseTest.java new file mode 100644 index 0000000..7df2001 --- /dev/null +++ b/src/test/java/com/ampersand/groom/domain/auth/application/usecase/SignInUseCaseTest.java @@ -0,0 +1,109 @@ +package com.ampersand.groom.domain.auth.application.usecase; + + +import com.ampersand.groom.domain.auth.exception.PasswordInvalidException; +import com.ampersand.groom.domain.auth.presentation.data.response.AuthTokenResponse; +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.global.security.jwt.data.TokenDto; +import com.ampersand.groom.global.security.jwt.service.JwtIssueService; +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 java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@DisplayName("로그인 UseCase 클래스의") +class SignInUseCaseTest { + + @Mock + private JwtIssueService jwtIssueService; + + @Mock + private MemberPersistencePort memberPersistencePort; + + @Mock + private BCryptPasswordEncoder passwordEncoder; + + @InjectMocks + private SignInUseCase signInUseCase; + + @Nested + @DisplayName("execute 메서드는") + class Describe_execute { + + @Nested + @DisplayName("비밀번호가 일치하면") + class Context_with_valid_password { + + @Test + @DisplayName("AccessToken과 RefreshToken을 반환한다.") + void it_returns_tokens() { + // Given + String email = "s00001@example.com"; + String password = "password123"; + String encodedPassword = "encodedPassword"; + MemberRole role = MemberRole.ROLE_STUDENT; + Member member = Member.builder() + .email(email) + .password(encodedPassword) + .role(role) + .build(); + TokenDto accessToken = new TokenDto("access-token", LocalDateTime.now().plusMinutes(30)); + TokenDto refreshToken = new TokenDto("refresh-token", LocalDateTime.now().plusDays(7)); + when(memberPersistencePort.findMemberByEmail(email)).thenReturn(member); + when(passwordEncoder.matches(password, encodedPassword)).thenReturn(true); + when(jwtIssueService.issueAccessToken(email, role)).thenReturn(accessToken); + when(jwtIssueService.issueRefreshToken(email)).thenReturn(refreshToken); + + // When + AuthTokenResponse response = signInUseCase.execute(email, password); + + // Then + assertAll( + () -> assertEquals(accessToken.token(), response.accessToken()), + () -> assertEquals(refreshToken.token(), response.refreshToken()), + () -> assertEquals(accessToken.expiration(), response.accessTokenExpiresAt()), + () -> assertEquals(refreshToken.expiration(), response.refreshTokenExpiresAt()), + () -> assertEquals(role, response.role()) + ); + } + } + + @Nested + @DisplayName("비밀번호가 일치하지 않으면") + class Context_with_invalid_password { + + @Test + @DisplayName("PasswordInvalidException을 던진다.") + void it_throws_PasswordInvalidException() { + // Given + String email = "s00001@example.com"; + String password = "wrongPassword"; + String encodedPassword = "encodedPassword"; + Member member = Member.builder() + .email(email) + .password(encodedPassword) + .role(MemberRole.ROLE_STUDENT) + .build(); + when(memberPersistencePort.findMemberByEmail(email)).thenReturn(member); + when(passwordEncoder.matches(password, encodedPassword)).thenReturn(false); + + // When & Then + assertThrows(PasswordInvalidException.class, () -> + signInUseCase.execute(email, password) + ); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/ampersand/groom/domain/auth/application/usecase/SignUpUseCaseTest.java b/src/test/java/com/ampersand/groom/domain/auth/application/usecase/SignUpUseCaseTest.java new file mode 100644 index 0000000..bc73154 --- /dev/null +++ b/src/test/java/com/ampersand/groom/domain/auth/application/usecase/SignUpUseCaseTest.java @@ -0,0 +1,133 @@ +package com.ampersand.groom.domain.auth.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.EmailFormatInvalidException; +import com.ampersand.groom.domain.auth.exception.UserExistException; +import com.ampersand.groom.domain.auth.exception.UserForbiddenException; +import com.ampersand.groom.domain.member.application.port.MemberPersistencePort; +import com.ampersand.groom.domain.member.domain.constant.MemberRole; +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.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("회원가입 UseCase 클래스의") +class SignUpUseCaseTest { + + @Mock + private BCryptPasswordEncoder passwordEncoder; + + @Mock + private MemberPersistencePort memberPersistencePort; + + @Mock + private AuthenticationPersistencePort authenticationPersistencePort; + + @InjectMocks + private SignUpUseCase signUpUseCase; + + @Nested + @DisplayName("execute 메서드는") + class Describe_execute { + + @Nested + @DisplayName("정상적인 회원가입 정보가 주어졌을 때") + class Context_with_valid_signup_info { + + @Test + @DisplayName("회원가입을 성공적으로 수행한다.") + void it_signs_up_user() { + // Given + String email = "s24001@gsm.hs.kr"; + String password = "password123"; + String encodedPassword = "encodedPassword"; + String name = "홍길동"; + when(authenticationPersistencePort.findAuthenticationByEmail(email)) + .thenReturn(Authentication.builder().email(email).verified(true).build()); + when(memberPersistencePort.existsMemberByEmail(email)).thenReturn(false); + when(passwordEncoder.encode(password)).thenReturn(encodedPassword); + + // When + signUpUseCase.execute(email, password, name); + + // Then + verify(memberPersistencePort).saveMember(argThat(member -> + member.getEmail().equals(email) + && member.getName().equals(name) + && member.getPassword().equals(encodedPassword) + && member.getRole().equals(MemberRole.ROLE_STUDENT) + && member.getGeneration() == 8 + && member.getIsAvailable() + )); + } + } + + @Nested + @DisplayName("이메일 인증이 안 된 경우") + class Context_with_unverified_authentication { + + @Test + @DisplayName("UserForbiddenException을 던진다.") + void it_throws_UserForbiddenException() { + // Given + String email = "s24001@gsm.hs.kr"; + when(authenticationPersistencePort.findAuthenticationByEmail(email)) + .thenReturn(Authentication.builder().email(email).verified(false).build()); + + // When & Then + assertThrows(UserForbiddenException.class, () -> + signUpUseCase.execute(email, "password", "홍길동") + ); + } + } + + @Nested + @DisplayName("이미 가입된 이메일인 경우") + class Context_with_existing_email { + + @Test + @DisplayName("UserExistException을 던진다.") + void it_throws_UserExistException() { + // Given + String email = "s24001@gsm.hs.kr"; + when(authenticationPersistencePort.findAuthenticationByEmail(email)) + .thenReturn(Authentication.builder().email(email).verified(true).build()); + when(memberPersistencePort.existsMemberByEmail(email)).thenReturn(true); + + // When & Then + assertThrows(UserExistException.class, () -> + signUpUseCase.execute(email, "password", "홍길동") + ); + } + } + + @Nested + @DisplayName("이메일 형식이 올바르지 않은 경우") + class Context_with_invalid_email_format { + + @Test + @DisplayName("EmailFormatInvalidException을 던진다.") + void it_throws_EmailFormatInvalidException() { + // Given + String email = "invalidemail.com"; + when(authenticationPersistencePort.findAuthenticationByEmail(email)) + .thenReturn(Authentication.builder().email(email).verified(true).build()); + when(memberPersistencePort.existsMemberByEmail(email)).thenReturn(false); + + // When & Then + assertThrows(EmailFormatInvalidException.class, () -> + signUpUseCase.execute(email, "password", "홍길동") + ); + } + } + } +} \ No newline at end of file diff --git a/src/test/java/com/ampersand/groom/domain/auth/application/usecase/VerifyEmailUseCaseTest.java b/src/test/java/com/ampersand/groom/domain/auth/application/usecase/VerifyEmailUseCaseTest.java new file mode 100644 index 0000000..6d41a8d --- /dev/null +++ b/src/test/java/com/ampersand/groom/domain/auth/application/usecase/VerifyEmailUseCaseTest.java @@ -0,0 +1,95 @@ +package com.ampersand.groom.domain.auth.application.usecase; + +import com.ampersand.groom.domain.auth.application.port.AuthCodePersistencePort; +import com.ampersand.groom.domain.auth.application.port.AuthenticationPersistencePort; +import com.ampersand.groom.domain.auth.domain.AuthCode; +import com.ampersand.groom.domain.auth.domain.Authentication; +import com.ampersand.groom.domain.auth.exception.VerificationCodeExpiredOrInvalidException; +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 static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("인증 이메일 확인 UseCase 클래스의") +class VerifyEmailUseCaseTest { + + @Mock + private AuthCodePersistencePort authCodePersistencePort; + + @Mock + private AuthenticationPersistencePort authenticationPersistencePort; + + @InjectMocks + private VerifyEmailUseCase verifyEmailUseCase; + + @Nested + @DisplayName("execute 메서드는") + class Describe_execute { + + @Nested + @DisplayName("유효한 인증 코드일 때") + class Context_with_valid_code { + + @Test + @DisplayName("인증 정보를 verified=true로 업데이트한다.") + void it_updates_authentication_to_verified() { + // Given + String code = "12345678"; + String email = "s00001@email.com"; + AuthCode authCode = AuthCode.builder() + .email(email) + .code(code) + .ttl(300L) + .build(); + Authentication authentication = Authentication.builder() + .email(email) + .attemptCount(1) + .verified(false) + .ttl(300L) + .build(); + when(authCodePersistencePort.existsAuthCodeByCode(code)).thenReturn(true); + when(authCodePersistencePort.findAuthCodeByCode(code)).thenReturn(authCode); + when(authenticationPersistencePort.findAuthenticationByEmail(email)).thenReturn(authentication); + + // When + verifyEmailUseCase.execute(code); + + // Then + verify(authCodePersistencePort).deleteAuthCodeByCode(code); + verify(authenticationPersistencePort).saveAuthentication(argThat(updated -> + updated.getEmail().equals(email) && + updated.getAttemptCount() == 1 && + Boolean.TRUE.equals(updated.getVerified()) && + updated.getTtl().equals(300L) + )); + } + } + + @Nested + @DisplayName("만료되었거나 존재하지 않는 인증 코드일 때") + class Context_with_invalid_code { + + @Test + @DisplayName("VerificationCodeExpiredOrInvalidException 예외를 던진다.") + void it_throws_VerificationCodeExpiredOrInvalidException() { + // Given + String invalidCode = "invalid"; + when(authCodePersistencePort.existsAuthCodeByCode(invalidCode)).thenReturn(false); + + // When & Then + assertThrows(VerificationCodeExpiredOrInvalidException.class, () -> + verifyEmailUseCase.execute(invalidCode) + ); + verify(authCodePersistencePort, never()).deleteAuthCodeByCode(any()); + verify(authenticationPersistencePort, never()).saveAuthentication(any()); + } + } + } +} \ No newline at end of file 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 index da31ff6..f4b5524 100644 --- 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 @@ -31,7 +31,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -@DisplayName("FindCurrentMemberUseCase 클래스의") +@DisplayName("현재 인증된 Member 조회 UseCase 클래스의") class FindCurrentMemberUseCaseTest { @Mock 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 index 9228b02..e7e4dd8 100644 --- 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 @@ -20,7 +20,7 @@ import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) -@DisplayName("UpdatePasswordUseCase 클래스의") +@DisplayName("Member 비밀번호 변경 UseCase 클래스의") class UpdatePasswordUseCaseTest { @Mock @@ -46,21 +46,18 @@ 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(memberPersistencePort.findMemberByEmail(email)).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")); + updatePasswordUseCase.execute(email, "oldPass", "newPass")); } } @@ -80,7 +77,7 @@ void it_throws_PasswordInvalidException() { .email(email) .password(encodedPassword) .build(); - when(memberPersistencePort.findMemberById(memberId)).thenReturn(member); + when(memberPersistencePort.findMemberByEmail(email)).thenReturn(member); when(authenticationPersistencePort.existsAuthenticationByEmail(email)).thenReturn(true); when(authenticationPersistencePort.findAuthenticationByEmail(email)) .thenReturn(Authentication.builder().email(email).verified(true).build()); @@ -88,7 +85,7 @@ void it_throws_PasswordInvalidException() { // When & Then assertThrows(PasswordInvalidException.class, () -> - updatePasswordUseCase.execute(memberId, "oldPass", "newPass")); + updatePasswordUseCase.execute(email, "oldPass", "newPass")); } } @@ -109,7 +106,7 @@ void it_updates_password_successfully() { .email(email) .password(encodedPassword) .build(); - when(memberPersistencePort.findMemberById(memberId)).thenReturn(member); + when(memberPersistencePort.findMemberByEmail(email)).thenReturn(member); when(authenticationPersistencePort.existsAuthenticationByEmail(email)).thenReturn(true); when(authenticationPersistencePort.findAuthenticationByEmail(email)) .thenReturn(Authentication.builder().email(email).verified(true).build()); @@ -117,7 +114,7 @@ void it_updates_password_successfully() { when(passwordEncoder.encode("newPass")).thenReturn(newEncodedPassword); // When - updatePasswordUseCase.execute(memberId, "oldPass", "newPass"); + updatePasswordUseCase.execute(email, "oldPass", "newPass"); // Then verify(memberPersistencePort).updateMemberPassword(memberId, newEncodedPassword);