프로젝트 전체 코드: https://github.com/kcm02/JWT_OAuth_Login.git
웹 사이트를 사용하다 보면 비밀번호를 잊을 때가 종종 있다. 이럴 때 억지로 로그인을 시도하다 보면, 계정이 잠겼으니 n분 후에 시도하라는 메시지가 뜬다.
이렇듯 웹 개발에서 로그인 실패 시 잠금 횟수를 증가시키고, 특정 횟수를 초과하면 계정을 잠그는 잠금 기능(Lock
)은 보안 강화를 위해 널리 사용되는 방법이다. 특히나 이 기능은 무차별 대입 공격(Brute Force Attack
)을 방지하고, 계정을 안전하게 보호하는 데 중요한 역할을 한다.
로그인 시도 횟수 추적:
failedLoginAttempts
필드를 추가한다.로그인 실패 처리:
failedLoginAttempts
필드의 값을 증가시킨다.accountNonLocked
와 같은 필드를 추가하여 계정 잠금 여부를 저장한다.계정 잠금 처리:
accountNonLocked
필드를 false
로 설정하여 계정을 잠근다.잠금 해제:
만약 잠금 처리 기능이 존재하지 않는다면 악성 해커가 특정 사용자의 이메일로 다량의 로그인 시도를 할 수 있으므로 보안이 매우 약화될 것이다. 그럼 이제부터 잠금 기능을 구현해 보자.
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
: 계정 잠금 시간 (계정이 잠긴 시간 기록)
incrementFailedLoginAttempts()
failedLoginAttempts
값을 1 증가시킨다.resetFailedLoginAttempts()
failedLoginAttempts
값을 0으로 초기화한다.lockAccount()
accountNonLocked
값을 false
로 설정한다.lockTime
에 기록하여 계정이 잠긴 시간을 저장한다.unlockAccount()
accountNonLocked
값을 true
로 설정한다.lockTime
값을 null
로 설정하여 잠금 시간을 초기화한다.isLockTimeExpired(int lockDurationMinutes)
lockDurationMinutes
: 잠금 해제 대기 시간(분 단위)lockTime
이 null
이면 잠금 시간이 없음을 의미하므로 바로 잠금 해제가 가능하다.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
: 계정 잠금 지속 시간 (분 단위)loadUserByUsername(String email)
email
: 사용자의 이메일 주소findUserByEmail
메서드를 호출하여 사용자를 찾는다.CustomUserDetails
객체를 반환한다.findUserByEmail(String email)
email
: 사용자의 이메일 주소UserRepository
를 사용하여 이메일로 사용자를 찾는다.UsernameNotFoundException
예외를 발생시킨다.handleAccountStatus(String email)
email
: 사용자의 이메일 주소findUserByEmail
메서드를 호출하여 사용자를 찾는다.UserNotEnabledException
예외를 발생시킨다.UserAccountLockedException
예외를 발생시킨다.processFailedLogin(String email)
email
: 사용자의 이메일 주소findUserByEmail
메서드를 호출하여 사용자를 찾는다.processSuccessfulLogin(String email)
email
: 사용자의 이메일 주소findUserByEmail
메서드를 호출하여 사용자를 찾는다.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
로그인 성공CustomUserDetailsService
의 processSuccessfulLogin
실행if
로그인 실패CustomUserDetailsService
의 handleAccountStatus
실행Spring Security
의 AuthenticationManager
는 로그인 실패 시 예외를 모두 AuthenticationException
으로 던지기 때문에, 직접 예외 처리 메서드를 통해 어떤 Exception
인지를 구분해 주어야 한다.CustomUserDetailsService
의 processFailedLogin
실행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
), 이 예외는 잘못된 이메일 주소나 비밀번호로 인해 발생하며, 남은 로그인 시도 횟수도 함께 반환한다.
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);
}
}
userRequestDTO
: 회원 가입을 위한 사용자 정보 DTOJsonResponse
객체로 포장하여 HTTP 응답으로 반환한다.IllegalStateException
발생 시 400 BAD_REQUEST
와 함께 이메일 중복 메시지를 보낸다.userId
: 수정할 회원의 ID (URL Path 변수로 전달됨)userRequestDTO
: 수정할 사용자 정보 DTOJsonResponse
객체로 포장하여 HTTP 응답으로 반환한다.IllegalStateException
발생 시 400 BAD_REQUEST
와 함께 메시지를 보낸다.Test
이제 직접 테스트를 진행해 보도록 하겠다.
테스트는 위와 같이 세 단계로 진행할 것이다.
만약 DB에 존재하지 않는 이메일을 입력하거나, 이메일 인증을 완료하기 전 로그인을 시도하면 각각 알맞은 메시지를 반환한다.
만약 로그인을 3번 이상 실패하면, 계정이 잠금 처리가 되고 DB에도 잘 반영되는 것을 확인할 수 있다.
일정 시간이 지나서 로그인 시도를 하면 정상적으로 처리되고, DB에도 잠금 처리가 풀린 것을 확인할 수 있다.
테스트를 위해 임시로 잠금 해제 시간을 1분으로 설정했다. 실제로는 더 긴 시간으로 설정해야 한다. (약 15분~30분 정도)
로그인 실패 횟수의 최대치, 잠금 해제 시간은 static
변수로 설정했다.