diff --git a/src/main/java/TtattaBackend/ttatta/config/security/CustomUserDetailsService.java b/src/main/java/TtattaBackend/ttatta/config/security/CustomUserDetailsService.java index 0f2df4f0..04f85f96 100644 --- a/src/main/java/TtattaBackend/ttatta/config/security/CustomUserDetailsService.java +++ b/src/main/java/TtattaBackend/ttatta/config/security/CustomUserDetailsService.java @@ -4,6 +4,7 @@ import TtattaBackend.ttatta.repository.UserRepository; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.LockedException; import org.springframework.security.core.userdetails.User; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; @@ -11,6 +12,8 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import java.time.Duration; +import java.time.LocalDateTime; import java.util.Collections; @Service @@ -20,13 +23,54 @@ public class CustomUserDetailsService implements CustomDetailsService { private final PasswordEncoder passwordEncoder; private final UserRepository userRepository; + private static final int MAX_ATTEMPTS = 5; + private static final Duration LOCK_DURATION = Duration.ofHours(12); + @Override public UserDetails loadUserByUsername(String username, String password) throws UsernameNotFoundException { Users user = userRepository.findByUsername(username) .orElseThrow(() -> new UsernameNotFoundException("해당 아이디를 가진 유저가 존재하지 않습니다: " + username)); + if (user.isLockedNow()) { + Duration remain = Duration.between(LocalDateTime.now(), user.getLockUntil()); + + long remainHours = remain.toHours(); + long remainMinutes = remain.toMinutes(); + + String message; + if (remainHours >= 1) { + message = "비밀번호 " + MAX_ATTEMPTS + "회 오류로 계정 잠긴 상태입니다. 약 " + (remainHours + 1) + "시간 후에 다시 시도해주세요."; + } else { + long remainMin = Math.max(1, remainMinutes); + message = "비밀번호 " + MAX_ATTEMPTS + "회 오류로 계정 잠긴 상태입니다. 약 " + remainMin + "분 후에 다시 시도해주세요."; + } + + throw new LockedException(message); + } else if (user.getLockUntil() != null) { + user.resetLock(); + userRepository.save(user); + } + if (!passwordEncoder.matches(password, user.getPassword())) { - throw new BadCredentialsException("Password가 일치하지 않습니다."); + Users refreshed = userRepository.findByUsername(username).orElse(user); + int attempts = refreshed.getFailedAttempts(); + int currentAttempts = attempts + 1; + + if (currentAttempts >= MAX_ATTEMPTS) { + refreshed.lockFor(LOCK_DURATION); + userRepository.save(refreshed); + throw new LockedException("비밀번호 " + MAX_ATTEMPTS + "회 오류로 계정 잠긴 상태입니다."); + } + + user.updateFailedAttempts(currentAttempts); + userRepository.save(user); + + throw new BadCredentialsException("아이디 또는 비밀번호가 올바르지 않습니다. (남은 시도: " + (MAX_ATTEMPTS - currentAttempts) + "회)"); + } + + if (user.getFailedAttempts() != 0 || user.getLockUntil() != null) { + user.resetLock(); + userRepository.save(user); } User securityUser = new User( diff --git a/src/main/java/TtattaBackend/ttatta/domain/Users.java b/src/main/java/TtattaBackend/ttatta/domain/Users.java index 6c8dd0a1..052131d7 100644 --- a/src/main/java/TtattaBackend/ttatta/domain/Users.java +++ b/src/main/java/TtattaBackend/ttatta/domain/Users.java @@ -11,6 +11,7 @@ import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; +import java.time.Duration; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -71,6 +72,11 @@ public class Users extends BaseEntity { @Column(length = 100) private String fcmToken; + @Column(nullable = false) + private int failedAttempts; + + private LocalDateTime lockUntil; + // 로그인 관련 // private LocalDateTime lastLogin; @@ -115,4 +121,20 @@ public void updateFcmToken(String fcmToken) { } public void updateStatus(UserStatus status) {this.status = status;} public void updatePinHash(String pinHash) {this.pinHash = pinHash;} + + public void updateFailedAttempts(int failedAttempts) { + this.failedAttempts = failedAttempts; + } + + public boolean isLockedNow() { + return lockUntil != null && LocalDateTime.now().isBefore(lockUntil); + } + public void resetLock() { + this.failedAttempts = 0; + this.lockUntil = null; + } + + public void lockFor(Duration duration) { + this.lockUntil = LocalDateTime.now().plus(duration); + } } \ No newline at end of file diff --git a/src/main/java/TtattaBackend/ttatta/service/UserService/UserCommandServiceImpl.java b/src/main/java/TtattaBackend/ttatta/service/UserService/UserCommandServiceImpl.java index 347e9a32..61404371 100644 --- a/src/main/java/TtattaBackend/ttatta/service/UserService/UserCommandServiceImpl.java +++ b/src/main/java/TtattaBackend/ttatta/service/UserService/UserCommandServiceImpl.java @@ -20,7 +20,6 @@ import TtattaBackend.ttatta.web.dto.UserRequestDTO; import TtattaBackend.ttatta.web.dto.UserResponseDTO; import jakarta.mail.internet.MimeMessage; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; @@ -28,11 +27,14 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.mail.javamail.JavaMailSender; import org.springframework.mail.javamail.MimeMessageHelper; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.LockedException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; import org.springframework.security.core.Authentication; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.thymeleaf.context.Context; import org.thymeleaf.spring6.SpringTemplateEngine; @@ -207,7 +209,7 @@ private void createDefaultCategory(Users newUser) { } @Override - @Transactional // ??? + @Transactional(noRollbackFor = {BadCredentialsException.class, LockedException.class}) public UserResponseDTO.UserSignInResultDTO signIn(UserRequestDTO.SignInRequestDTO request) { UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()); Authentication authentication = authenticationManagerBuilder.getObject().authenticate(authenticationToken); diff --git a/src/main/java/TtattaBackend/ttatta/web/dto/UserRequestDTO.java b/src/main/java/TtattaBackend/ttatta/web/dto/UserRequestDTO.java index 06d7d1f2..788432e8 100644 --- a/src/main/java/TtattaBackend/ttatta/web/dto/UserRequestDTO.java +++ b/src/main/java/TtattaBackend/ttatta/web/dto/UserRequestDTO.java @@ -1,5 +1,6 @@ package TtattaBackend.ttatta.web.dto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; @@ -27,6 +28,10 @@ public static class SignUpRequestDTO { @Size(min = 6, max = 15, message = "아이디는 6 ~ 15자이어야 합니다.") private String username; @NotBlank(message = "비밀번호는 빈값일 수 없습니다.") + @Schema(example = "string123!#") + @Size(min = 8) + @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[^A-Za-z0-9]).+$", + message = "비밀번호는 영문자, 숫자, 특수문자를 각각 1자 이상 포함해야 합니다.") private String password; }