Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@
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;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
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
Expand All @@ -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 + "분 후에 다시 시도해주세요.";
}
Comment on lines +37 to +46

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

계정 잠금 시 남은 시간을 안내하는 메시지가 사용자에게 혼란을 줄 수 있습니다. 현재 로직은 남은 시간을 시간 단위로 올림하여 보여주기 때문에, 예를 들어 1시간 1분이 남았을 때와 1시간 59분이 남았을 때 모두 "약 2시간"으로 표시됩니다. 사용자에게 더 정확한 정보를 제공하기 위해 시간과 분을 함께 표시하는 것을 고려해보세요.

Suggested change
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 + "분 후에 다시 시도해주세요.";
}
long remainHours = remain.toHours();
long remainMinutesPart = remain.toMinutes() % 60;
String message;
if (remainHours >= 1) {
message = "비밀번호 " + MAX_ATTEMPTS + "회 오류로 계정 잠긴 상태입니다. 약 " + remainHours + "시간 " + remainMinutesPart + "분 후에 다시 시도해주세요.";
} else {
long remainMin = Math.max(1, remain.toMinutes());
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) + "회)");
}
Comment on lines 54 to +69

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

로그인 실패 시 사용자 정보를 업데이트하는 로직에 동시성 문제가 발생할 수 있으며, 코드가 복잡합니다. userrefreshed라는 두 개의 다른 객체 인스턴스를 사용하여 데이터를 업데이트하고 있어, 동시 요청 시 데이터 불일치가 발생할 수 있습니다. 예를 들어, refreshed 객체로 잠금 여부를 확인하고 잠금을 설정하지만, 잠기지 않은 경우에는 원본 user 객체의 실패 횟수를 업데이트하고 저장합니다. 이로 인해 failedAttempts가 정확히 증가하지 않을 수 있습니다. 하나의 사용자 객체 인스턴스를 일관되게 사용하고, 데이터베이스 저장 연산을 한 번으로 줄여 코드를 더 명확하고 안전하게 만드는 것이 좋습니다.

        if (!passwordEncoder.matches(password, user.getPassword())) {
            // 동시 로그인 시도에 대한 경쟁 상태를 방지하기 위해 사용자 정보를 다시 가져옵니다.
            Users userToUpdate = userRepository.findByUsername(username)
                    .orElseThrow(() -> new UsernameNotFoundException("해당 아이디를 가진 유저가 존재하지 않습니다: " + username));

            int newAttempts = userToUpdate.getFailedAttempts() + 1;
            userToUpdate.updateFailedAttempts(newAttempts);

            if (newAttempts >= MAX_ATTEMPTS) {
                userToUpdate.lockFor(LOCK_DURATION);
            }
            userRepository.save(userToUpdate);

            if (newAttempts >= MAX_ATTEMPTS) {
                throw new LockedException("비밀번호 " + MAX_ATTEMPTS + "회 오류로 계정 잠긴 상태입니다.");
            } else {
                throw new BadCredentialsException("아이디 또는 비밀번호가 올바르지 않습니다. (남은 시도: " + (MAX_ATTEMPTS - newAttempts) + "회)");
            }
        }


if (user.getFailedAttempts() != 0 || user.getLockUntil() != null) {
user.resetLock();
userRepository.save(user);
}

User securityUser = new User(
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/TtattaBackend/ttatta/domain/Users.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,19 +20,21 @@
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;
import org.springframework.core.io.ClassPathResource;
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;

Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down