[프로젝트] Spring Security + OAuth + JWT + Redis를 활용한 로그인 및 회원가입 구현 (9) - 로그인 실패 시 잠금 처리

김찬미·2024년 7월 18일
0
post-thumbnail

프로젝트 전체 코드: https://github.com/kcm02/JWT_OAuth_Login.git

🔒 잠금 처리란?

웹 사이트를 사용하다 보면 비밀번호를 잊을 때가 종종 있다. 이럴 때 억지로 로그인을 시도하다 보면, 계정이 잠겼으니 n분 후에 시도하라는 메시지가 뜬다.

이렇듯 웹 개발에서 로그인 실패 시 잠금 횟수를 증가시키고, 특정 횟수를 초과하면 계정을 잠그는 잠금 기능(Lock)은 보안 강화를 위해 널리 사용되는 방법이다. 특히나 이 기능은 무차별 대입 공격(Brute Force Attack)을 방지하고, 계정을 안전하게 보호하는 데 중요한 역할을 한다.

🔄️ 잠금 처리 흐름

  1. 로그인 시도 횟수 추적:

    • 사용자가 로그인할 때마다 로그인 시도 횟수를 기록한다. 일반적으로 데이터베이스의 사용자 테이블에 failedLoginAttempts 필드를 추가한다.
  2. 로그인 실패 처리:

    • 사용자가 로그인에 실패할 때마다 failedLoginAttempts 필드의 값을 증가시킨다.
    • 만약 로그인 시도 횟수가 설정된 최대 허용 횟수를 초과하면 계정을 잠금 처리한다. 이 경우 사용자 테이블에 accountNonLocked와 같은 필드를 추가하여 계정 잠금 여부를 저장한다.
  3. 계정 잠금 처리:

    • 로그인 시도 횟수가 최대 허용 횟수를 초과하면 accountNonLocked 필드를 false로 설정하여 계정을 잠근다.
    • 계정이 잠긴 사용자는 추가 로그인 시도 시 경고 메시지를 받게 된다.
  4. 잠금 해제:

    • 잠금 해제는 관리자에 의해 수동으로 이루어질 수 있으며, 일정 시간이 지나면 자동으로 잠금이 해제되도록 구현할 수도 있다.

만약 잠금 처리 기능이 존재하지 않는다면 악성 해커가 특정 사용자의 이메일로 다량의 로그인 시도를 할 수 있으므로 보안이 매우 약화될 것이다. 그럼 이제부터 잠금 기능을 구현해 보자.


🗂️ 패키지 구조

com.project.securelogin
├── config
│   └── RedisConfig.java
│   └── SecurityConfig.java
├── controller
│   └── AuthController.java ✔️
│   └── UserController.java ✔️
├── domain
│   └── CustomUserDetails.java ✔️
│   └── User.java ✔️
├── dto
│   └── JsonResponse.java
│   └── UserRequestDTO.java
│   └── UserResponseDTO.java
├── exception
│   └── UserAccountLockedException.java ✔️
│   └── UserNotEnabledException.java ✔️
├── jwt
│   └── JwtAuthenticationFilter.java
│   └── JwtTokenProvider.java
├── repository
│   └── JwtTokenRedisRepository.java
│   └── UserRepository.java
└── service
    └── AuthService.java ✔️
    └── CustomUserDetailsService.java ✔️
    └── MailService.java
    └── UserService.java

✔️ 변경된 클래스

  • User: 계정 잠금 및 잠금 해제 관련 메서드 추가

  • CustomUserDetails: User에 추가된 필드, 메서드 추가

  • UserAccountLockedException: 계정 잠금 예외 클래스 추가

  • UserNotEnabledException: 계정 비활성화 예외 클래스 추가

  • CustomUserDetailsService: 기존 메서드 수정 및 잠금 관련 메서드 추가

  • AuthService: 로그인 성공, 실패 시 CustomUserDetailsService의 메서드 실행

  • AuthController: 로그인 API의 예외 처리 강화

  • UserController: 회원 가입 및 정보 수정 시 이메일 인증 관련 메시지 추가


User

    private int failedLoginAttempts; // 로그인 시도 횟수
    private LocalDateTime lockTime; // 계정 잠금 해제 시간

    // 로그인 실패 시 로그인 시도 횟수 증가
    public void incrementFailedLoginAttempts() {
        this.failedLoginAttempts++;
    }

    // 로그인 성공 시 로그인 시도 횟수 초기화
    public void resetFailedLoginAttempts() {
        this.failedLoginAttempts = 0;
    }

    // 계정 잠금
    public void lockAccount() {
        this.accountNonLocked = false;
        this.lockTime = LocalDateTime.now();
    }

    // 계정 잠금 풀기
    public void unlockAccount() {
        this.accountNonLocked = true;
        this.lockTime = null;
    }

    public boolean isLockTimeExpired(int lockDurationMinutes) {
        if (this.lockTime == null) {
            return true; // 잠금 시간이 없으면 바로 해제 가능
        }
        LocalDateTime expiryTime = this.lockTime.plusMinutes(lockDurationMinutes);
        return expiryTime.isBefore(LocalDateTime.now()); // 현재 시간이 잠금 만료 시간 이전이면 해제 가능
    }

✅ 구성 요소

  • failedLoginAttempts: 로그인 실패 횟수

  • lockTime: 계정 잠금 시간 (계정이 잠긴 시간 기록)

✅ 메서드

1) incrementFailedLoginAttempts()

  • 기능: 로그인 실패 시 로그인 시도 횟수를 증가시키는 메서드
  • 동작:
    • 로그인 시도가 실패할 때마다 호출되어 failedLoginAttempts 값을 1 증가시킨다.

2) resetFailedLoginAttempts()

  • 기능: 로그인 성공 시 로그인 시도 횟수를 초기화하는 메서드
  • 동작:
    • 로그인에 성공하면 failedLoginAttempts 값을 0으로 초기화한다.

3) lockAccount()

  • 기능: 계정을 잠그는 메서드
  • 동작:
    • 계정을 잠그고 accountNonLocked 값을 false로 설정한다.
    • 현재 시간을 lockTime에 기록하여 계정이 잠긴 시간을 저장한다.

4) unlockAccount()

  • 기능: 계정을 잠금 해제하는 메서드
  • 동작:
    • 계정을 잠금 해제하고 accountNonLocked 값을 true로 설정한다.
    • lockTime 값을 null로 설정하여 잠금 시간을 초기화한다.

5) isLockTimeExpired(int lockDurationMinutes)

  • 기능: 계정 잠금 시간이 만료되었는지 확인하는 메서드
  • 매개변수:
    • lockDurationMinutes: 잠금 해제 대기 시간(분 단위)
  • 동작:
    • lockTimenull이면 잠금 시간이 없음을 의미하므로 바로 잠금 해제가 가능하다.
    • lockTime에 기록된 시간에 lockDurationMinutes를 더한 시간이 현재 시간보다 이전인지를 확인하여 잠금 만료 여부를 반환한다.

CustomUserDetails

@Getter
public class CustomUserDetails implements UserDetails {

    private final User user;

    public CustomUserDetails(User user) {
        this.user = user;
    }

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return Collections.emptyList();
    }

    @Override
    public String getPassword() {
        return user.getPassword();
    }
    
    ··· 생략 ···

✅ 변경 사항

  • User의 필드 및 메서드를 전부 받아온다.
  • User가 변경되었으니 CustomUserDetails에도 필드, 메서드를 추가해야 한다.

Exception

public class UserAccountLockedException extends AuthenticationException {
    public UserAccountLockedException(String message) {
        super(message);
    }
}

public class UserNotEnabledException extends AuthenticationException {
    public UserNotEnabledException(String message) {
        super(message);
    }
}

🚨 UserAccountLockedException

사용자가 로그인 시도 중 계정이 잠금 상태일 때 발생하는 예외

🚨 UserNotEnabledException

사용자가 로그인 시도 중 계정이 활성화되지 않았을 때 발생하는 예외

💡 커스텀 예외(Custom Exception)란?

  • 커스텀 예외란 애플리케이션에서 특정한 상황에 맞는 예외를 처리하기 위해 사용자가 직접 정의하는 예외 클래스이다.
  • 커스텀 예외를 사용하면 예외 상황을 더 명확하게 구분할 수 있다.
  • 표준 예외 클래스(ex)AuthenticationException)를 상속받아 필요에 따라 추가적인 필드나 메서드를 정의해서 사용한다.

CustomUserDetailsService

@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    private static final int MAX_FAILED_ATTEMPTS = 3;
    private static final int LOCKOUT_MINUTES = 1;

    @Override
    public UserDetails loadUserByUsername(String email) {
        User user = findUserByEmail(email);

        // 잠금 해제 처리
        if (!user.isAccountNonLocked() && user.isLockTimeExpired(LOCKOUT_MINUTES)) {
            user.unlockAccount();
            user.resetFailedLoginAttempts();
            userRepository.save(user);
        }

        return new CustomUserDetails(user);
    }

    private User findUserByEmail(String email) {
        return userRepository.findByEmail(email)
                .orElseThrow(() -> new UsernameNotFoundException("해당 이메일을 가진 사용자를 찾을 수 없습니다."));
    }

    public void handleAccountStatus(String email) {
        User user = findUserByEmail(email);

        // 계정이 활성화되지 않은 경우 예외 발생
        if (!user.isEnabled()) {
            throw new UserNotEnabledException("계정이 활성화되지 않았습니다. 이메일 인증을 완료해주세요.");
        }

        // 계정이 잠금된 경우 예외 발생
        if (!user.isAccountNonLocked()) {
            throw new UserAccountLockedException("계정이 잠금되었습니다. " + user.getLockTime().plusMinutes(LOCKOUT_MINUTES) + " 이후에 다시 시도해주세요.");
        }
    }

    public void processFailedLogin(String email) {
        userRepository.findByEmail(email).ifPresent(user -> {
            user.incrementFailedLoginAttempts();
            if (user.getFailedLoginAttempts() >= MAX_FAILED_ATTEMPTS) {
                user.lockAccount();
            }
            userRepository.save(user);
        });
    }

    public void processSuccessfulLogin(String email) {
        userRepository.findByEmail(email).ifPresent(user -> {
            user.resetFailedLoginAttempts();
            userRepository.save(user);
        });
    }

    public int getRemainingLoginAttempts(String email) {
        return userRepository.findByEmail(email)
                .map(User::getFailedLoginAttempts)
                .filter(attempts -> attempts < MAX_FAILED_ATTEMPTS) // 잠금 전까지만 표시
                .map(attempts -> MAX_FAILED_ATTEMPTS - attempts + 1)
                .orElse(1);
    }
}

✅ 구성 요소

  • MAX_FAILED_ATTEMPTS: 최대 허용 로그인 실패 횟수
  • LOCKOUT_MINUTES: 계정 잠금 지속 시간 (분 단위)

✅ 메서드

1) loadUserByUsername(String email)

  • 기능: 이메일로 사용자를 로드하는 메서드
  • 매개변수:
    • email: 사용자의 이메일 주소
  • 동작:
    • findUserByEmail 메서드를 호출하여 사용자를 찾는다.
    • 계정이 잠금된 상태이고 잠금 시간이 만료되었으면 계정을 잠금 해제하고 로그인 실패 횟수를 초기화한다.
    • CustomUserDetails 객체를 반환한다.

2) findUserByEmail(String email)

  • 기능: 이메일로 사용자를 찾는 메서드
  • 매개변수:
    • email: 사용자의 이메일 주소
  • 동작:
    • UserRepository를 사용하여 이메일로 사용자를 찾는다.
    • 사용자가 존재하지 않으면 UsernameNotFoundException 예외를 발생시킨다.
    • 사용자가 존재하면 해당 사용자 객체를 반환한다.

3) handleAccountStatus(String email)

  • 기능: 사용자의 계정 상태를 확인하고 예외를 발생시키는 메서드
  • 매개변수:
    • email: 사용자의 이메일 주소
  • 동작:
    • findUserByEmail 메서드를 호출하여 사용자를 찾는다.
    • 계정이 활성화되지 않았으면 UserNotEnabledException 예외를 발생시킨다.
    • 계정이 잠금된 상태이면 UserAccountLockedException 예외를 발생시킨다.

4) processFailedLogin(String email)

  • 기능: 로그인 실패 시 호출되는 메서드
  • 매개변수:
    • email: 사용자의 이메일 주소
  • 동작:
    • findUserByEmail 메서드를 호출하여 사용자를 찾는다.
    • 로그인 실패 횟수를 증가시키고, 실패 횟수가 최대치를 초과하면 계정을 잠근다.
    • 변경된 사용자 정보를 저장한다.

5) processSuccessfulLogin(String email)

  • 기능: 로그인 성공 시 호출되는 메서드
  • 매개변수:
    • email: 사용자의 이메일 주소
  • 동작:
    • findUserByEmail 메서드를 호출하여 사용자를 찾는다.
    • 로그인 실패 횟수를 초기화하고, 변경된 사용자 정보를 저장한다.

6) getRemainingLoginAttempts(String email)

  • 기능: 남은 로그인 시도 횟수를 반환하는 메서드
  • 매개변수:
    • email: 사용자의 이메일 주소
  • 동작:
    • findUserByEmail 메서드를 호출하여 사용자를 찾는다.
    • 사용자의 로그인 실패 횟수를 확인하고, 남은 로그인 시도 횟수를 계산하여 반환한다.

AuthService

    private final CustomUserDetailsService userDetailsService;

    public HttpHeaders login(String email, String password) {
        try {
        
			··· 생략 ···
            
            userDetailsService.processSuccessfulLogin(email);

			··· 생략 ···
            
        } catch (AuthenticationException e) {
            // 로그인 실패 처리
            userDetailsService.handleAccountStatus(email);
            userDetailsService.processFailedLogin(email);
            // 예외를 던짐
            throw e;
        }
    }

    public int getRemainingLoginAttempts(String email) {
        return userDetailsService.getRemainingLoginAttempts(email);
    }

✅ 변경사항

if 로그인 성공

  • CustomUserDetailsServiceprocessSuccessfulLogin 실행

if 로그인 실패

  • CustomUserDetailsServicehandleAccountStatus 실행
    • Spring SecurityAuthenticationManager는 로그인 실패 시 예외를 모두 AuthenticationException으로 던지기 때문에, 직접 예외 처리 메서드를 통해 어떤 Exception인지를 구분해 주어야 한다.
  • CustomUserDetailsServiceprocessFailedLogin 실행
    • 위 메서드에서 예외가 발생하지 않았다면, 일반적인 로그인 실패 (이메일 or 비밀번호가 맞지 않음)이므로 그대로 예외를 던진다.

AuthController

    @PostMapping("/login")
    public ResponseEntity<JsonResponse> login(@RequestBody AuthRequest authRequest) {
        try {
            HttpHeaders headers = authService.login(authRequest.getEmail(), authRequest.getPassword());
            JsonResponse response = new JsonResponse(HttpStatus.OK.value(), "로그인에 성공했습니다.", null);
            return ResponseEntity.ok().headers(headers).body(response);
        } catch (UsernameNotFoundException e) {
            return ResponseEntity.status(HttpStatus.NOT_FOUND)
                    .body(new JsonResponse(HttpStatus.NOT_FOUND.value(), e.getMessage(), null));
        } catch (UserNotEnabledException e) {
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(new JsonResponse(HttpStatus.UNAUTHORIZED.value(), e.getMessage(), null));
        } catch (UserAccountLockedException e) {
            return ResponseEntity.status(HttpStatus.LOCKED)
                    .body(new JsonResponse(HttpStatus.LOCKED.value(), e.getMessage(), null));
        } catch (AuthenticationException e) {
            int remainingAttempts = authService.getRemainingLoginAttempts(authRequest.getEmail());
            String message = "이메일 주소나 비밀번호가 올바르지 않습니다. " +
                    remainingAttempts + "번 더 로그인에 실패하면 계정이 잠길 수 있습니다.";
            return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
                    .body(new JsonResponse(HttpStatus.UNAUTHORIZED.value(), message, null));
        }
    }

✅ ("/login") API 변경 사항

  • PostMapping("/login"): HTTP POST 요청을 /login 엔드포인트에서 처리한다.

  • login(@RequestBody AuthRequest authRequest): AuthRequest 객체를 요청 본문에서 받아와서 사용자 인증을 시도한다. AuthRequest는 클라이언트가 제공한 사용자 이메일과 비밀번호를 포함한다.

  • try-catch 블록: 다양한 예외 상황에 대응하기 위해 각각의 예외 타입에 따라 다른 HTTP 상태 코드와 메시지를 반환한다.

    • UsernameNotFoundException: 사용자 이름을 찾을 수 없는 경우 (404 - NOT FOUND)

    • UserNotEnabledException: 사용자 계정이 비활성화된 경우 (401 - UNAUTHORIZED)

    • UserAccountLockedException: 사용자 계정이 잠긴 경우 (423 - LOCKED)

    • AuthenticationException: 기타 인증 예외 (401 - UNAUTHORIZED), 이 예외는 잘못된 이메일 주소나 비밀번호로 인해 발생하며, 남은 로그인 시도 횟수도 함께 반환한다.

  1. ResponseEntity: 각 예외에 대한 적절한 HTTP 상태 코드와 메시지를 포함하는 JsonResponse 객체를 반환한다.

UserController

	// 회원 가입 API
    @PostMapping("/signup")
    // @Valid 어노테이션을 사용해 `SignUpRequest`의 유효성 검사를 활성화, 통과한 경우 서비스 코드 호출
    public ResponseEntity<JsonResponse> signUp(@Valid @RequestBody UserRequestDTO userRequestDTO) {
        try {
            UserResponseDTO userResponseDTO = userService.signUp(userRequestDTO);
            JsonResponse response = new JsonResponse(HttpStatus.CREATED.value(), "회원 가입이 성공적으로 실행되었습니다. 이메일 인증을 완료해주세요.", userResponseDTO);
            return ResponseEntity.ok().body(response);
        } catch (IllegalStateException e) {
            JsonResponse errorResponse = new JsonResponse(HttpStatus.BAD_REQUEST.value(), "이미 등록된 이메일입니다.", null);
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
        }
    }
    
    // 회원 정보 수정
    @PutMapping("/{userId}")
    public ResponseEntity<JsonResponse> updateUser(@PathVariable Long userId, @Valid @RequestBody UserRequestDTO userRequestDTO) {
        try {
            UserResponseDTO userResponseDTO = userService.updateUser(userId, userRequestDTO);
            JsonResponse response = new JsonResponse(HttpStatus.OK.value(), "회원 정보를 성공적으로 수정했습니다. 이메일 인증을 완료해주세요.", userResponseDTO);
            return ResponseEntity.ok().body(response);
        } catch (IllegalStateException e) {
            JsonResponse errorResponse = new JsonResponse(HttpStatus.BAD_REQUEST.value(), e.getMessage(), null);
            return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse);
        }
    }

📝 회원 가입 API

  • 기능: 회원 가입 처리 API
  • 매개변수:
    • userRequestDTO: 회원 가입을 위한 사용자 정보 DTO
  • 동작:
    • 클라이언트가 제공한 사용자 정보로 회원 가입을 시도하고, 성공 시 회원 정보를 반환한다.
    • 반환된 정보는 JsonResponse 객체로 포장하여 HTTP 응답으로 반환한다.
  • 예외 처리:
    • IllegalStateException 발생 시 400 BAD_REQUEST와 함께 이메일 중복 메시지를 보낸다.

🖊️ 회원 정보 수정 API

  • 기능: 회원 정보 수정 API
  • 매개변수:
    • userId: 수정할 회원의 ID (URL Path 변수로 전달됨)
    • userRequestDTO: 수정할 사용자 정보 DTO
  • 동작:
    • 주어진 사용자 ID와 정보로 회원 정보를 수정하고, 수정된 회원 정보를 반환한다.
    • 반환된 정보는 JsonResponse 객체로 포장하여 HTTP 응답으로 반환한다.
  • 예외 처리:
    • IllegalStateException 발생 시 400 BAD_REQUEST와 함께 메시지를 보낸다.

Test

이제 직접 테스트를 진행해 보도록 하겠다.

  • 예외 처리
  • 계정 잠금
  • 잠금 해제

테스트는 위와 같이 세 단계로 진행할 것이다.

🚨 예외 처리

image
image

만약 DB에 존재하지 않는 이메일을 입력하거나, 이메일 인증을 완료하기 전 로그인을 시도하면 각각 알맞은 메시지를 반환한다.

🔒 계정 잠금

image
image
image

만약 로그인을 3번 이상 실패하면, 계정이 잠금 처리가 되고 DB에도 잘 반영되는 것을 확인할 수 있다.

🔓 잠금 해제

image
image

일정 시간이 지나서 로그인 시도를 하면 정상적으로 처리되고, DB에도 잠금 처리가 풀린 것을 확인할 수 있다.

기타 정보

  • 테스트를 위해 임시로 잠금 해제 시간을 1분으로 설정했다. 실제로는 더 긴 시간으로 설정해야 한다. (약 15분~30분 정도)

  • 로그인 실패 횟수의 최대치, 잠금 해제 시간은 static 변수로 설정했다.

profile
백엔드 개발자

0개의 댓글

관련 채용 정보