본문 바로가기
Programming/Spring Boot

Spring Security - BadCredentialsException

by 공부합시다홍아 2024. 6. 15.

이전에 공부를 한 부분이지만, 아직 Spring Security에 대한 이해도가 낮은 것은 사실입니다..
그래서 해당 게시글은 원래도 스스로 학습의 개념이지만, 그냥 기록상의 이유 그리고 나중에 찾아보는 용도로 정리하려고 한다.


BadCredentialsException

로그인 기능을 구현할 때, 원인을 모르는 로그인 실패시에 로그도 확인이 안되 아래와 같이 디버그를 진행해서 어떤 문제가 있었는지 확인할 수 있었다. 그리고 해당 커스터마이징 과정은 가장 아래 git 을 통해 내려받을 수 있다.

org.springframework.security.authentication.BadCredentialsException:자격증명에 실패하였습니다. 

위의 이유를 자세히 보면 BadCredentialsException 이라는 예외가 발생했음을 확인할 수가 있다. 이전에 데이터가 제대로 들어오지 않는 것때문에 애를 먹은 적이 있어서, 개발 단계에서 항상 데이터의 송수신을 확인할 수 있는 출력 구문을 적어놓는 것이 습관이 되었다. 이때 아이디의 입력 값은 정확하게 찾아오는 것을 확인할 수 있었는데, 해당 부분은 검색을 해보니 보통 보안을 위해 " 아이디가 틀릴 때나 비밀번호가 틀렸을 경우를 알려주지 않기 위해 발생시키는 예외"라는 것을 알 수 있었다.

그럼 해당 예외를 통해서 어떻게 처리를 해야할까라는 고민이 들었을 때, 나는 Username을 기준으로 비밀번호가 데이터베이스 내의 암호화된 비밀번호와 일치하지 않을 때, 카운팅을 한 다음에 최대 5회이상 비밀번호가 틀렸을 경우, 계정을 잠기게 끔 구현을 해보았다.

먼저 아래는 SpringConfig로 스프링 시큐리티를 설정파일에 등록을 한다는 의사 표현을 한 클래스 파일이다.

SpringConfig.JAVA

@Configuration
@EnableWebSecurity
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class SecurityConfig {

	@Autowired
	private MyUserDetailsService myUserDetailsService;
	
	@Bean
	public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception{
		
		http.csrf().disable();
		
		http.authorizeRequests( (a) -> a
                ... 생략
        );
		
		http.formLogin()
			.loginPage("/main")
			.loginProcessingUrl("/login/loginForm")
			.defaultSuccessUrl("/test")
			.failureHandler(authenticationFailureHandler())
			.and()
			.logout().logoutUrl("/myLogout").logoutSuccessUrl("/main")
        ;

		
		return http.build();
	}
	
	// 인증 실패 핸들러
	@Bean
	public AuthenticationFailureHandler authenticationFailureHandler() {
		CustomAuthenticationFailure custom = new CustomAuthenticationFailure();
		custom.setRedirectURL("/login?err=true");
		
		return custom;
	}
	
}

모듈과 라이브러리를 생략하고, 페이지 접근 권한을 해주는 authorizeRequests 에 대한 부분도 생략을 하겠다.

먼저 확인해야할 부분은 formLogin시 로그인에 실패할 경우 사용하는 핸들러인 failureHandler이다.
failureHandler의 경우, authenticationFailureHandler를 받아서 사용하기 때문에, 인증 실패 핸들러를 빈 객체에 등록하고, 인증 실패 핸들러에 대한 기능을 구현해줄 커스터마이징 핸들러를 하나 만들어줘야 한다.

CustomAuthenticationFailure.JAVA

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InsufficientAuthenticationException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;

import com.project.myTest.service.common.LoginMapper;

public class CustomAuthenticationFailure extends SimpleUrlAuthenticationFailureHandler{

	private String redirectURL;
	private static final int lockyn = 5;
	private Map<String, Integer> loginAttemps = new HashMap<>();
	
	@Autowired
	private LoginMapper loginMapper;
	
	// 인증 실패시 실행
	@Override
	public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
			AuthenticationException exception) throws IOException, ServletException {
		// 로그인 실패시에 다양한 작업 처리
		
		String username = request.getParameter("username");
		System.out.println("fails : " + username);
		String msg;
		
		if(exception instanceof BadCredentialsException) {
			int attempts = loginAttemps.getOrDefault(username, 0);
			attempts++;
			loginAttemps.put(username, attempts);
			
			if(attempts >= lockyn) {
				lockCnt(username);
				msg = "잠김 계정이오니, 관리자에게 문의바랍니다.";
			}else {
				System.out.println(attempts + " / " + lockyn);
				msg = "아이디와 비밀번호를 확인해주세요. 로그인 시도 : " + attempts + "/ 총" + lockyn;
			}
			
		}else if(exception instanceof InsufficientAuthenticationException) {
			msg = "비밀번호가 틀렸습니다.";
		}
		
		
		response.sendRedirect(redirectURL);
	}
	
	private void lockCnt(String username) {
		loginMapper.lock(username);
		System.out.println("잠긴 계정 : " + username);
	}

	public String getRedirectURL() {
		return redirectURL;
	}

	public void setRedirectURL(String redirectURL) {
		this.redirectURL = redirectURL;
	}
	
}

나는 해당 부분에서 핸들러를 커스터마징을 진행해주었다.
먼저 실행 결과는 아래와 같다.

위 결과를 보면, 로그인 시도가 5회 진행되고 5회가 되는 순간 데이터베이스의 lockyn의 값을 update 해줌으로써, 계정을 잠근 상태로 만들 수 있었다.

순서대로 구문을 설명하면 아래와 같다.

private String redirectURL;
	private static final int lockyn = 5;
	private Map<String, Integer> loginAttemps = new HashMap<>();
  • 실패시 리다이렉트할 URL을 저장
  • 최대 허용되는 실패 시도 횟수를 상수로 정의
  • 각 사용자의 로그인 실패 시도 횟수를 저장하기 위한 맵이다. 키는 사용자아이디, 값은 실패 횟수로 저장한다.

 

@Autowired
private UserMapper userMapper;
  • Mapper는 굳이 설명안해도 될 꺼 같은데, update 구문을 가져오기 위해 autowired로 맵핑을 시켜주었다.

 

@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
        AuthenticationException exception) throws IOException, ServletException {

    String username = request.getParameter("username");
    String msg;
    System.out.println("로그인 실패");

    if(exception instanceof BadCredentialsException) {
        int attempts = loginAttemps.getOrDefault(username, 0);
        attempts++;
        loginAttemps.put(username, attempts);
  • public void onAuthenticationFailure
    : onAuthenticationFailure의 경우 인증 실패시 호출되는 메서드로, failureHandler를 커스터마이징할 때 필수 요소이다.
  • Stringusername = request.getParameter("username");
    : 요청파라미터에서 username을 가져온다.
  • String msg
    : 로그인 실패시 혹은 실패 횟 수에 대한 메세지를 담을 공간
  • if(exception instanceof BadCredentialsException) {}
    : BadCrendentialException 예외 인지 아닌지 확인
  • int attempts = loginAttempts.getOrDefault(username, 0);
    실패 시도 횟 수를 저장을 하는 변수로, getOrDefault라는 Map의 내장객체를 이용해 null 값 대신 기본 값을 username과 0 으로 사용할 수 있도록 설정한다.
  • attempts++;
    : 로그인이 실패 했을 경우, 위의 선언한 실패 시도 횟수를 1씩 증가시킨다.
  • loginAttempts.put(username, attempts);
    그리고 유저의 아이디와, 로그인 시도 횟 수를 Map에 저장시켜준다.

여기까지 작업이 끝나면, 로그인 실패시 횟 수를 증감시켜주는 역할을 하게 되고, 아래 추가 적인 설명 부분에서 조건에 따라 비즈니스로직을 실행시켜준다.

			if(attempts >= lockyn) {
				lockCnt(username);
				msg = "잠김 계정이오니, 관리자에게 문의바랍니다.";
			}else {
				System.out.println(attempts + " / " + lockyn);
				msg = "아이디와 비밀번호를 확인해주세요. 로그인 시도 : " + attempts + "/ 총" + lockyn;
			}
			
		}else if(exception instanceof InsufficientAuthenticationException) {
			msg = "아이디 혹은 비밀번호가 틀렸습니다."
		}
		
		response.sendRedirect(redirectURL);
	}
  • 맨 처음 5회로 지정해둔, 최대 허용 횟수보다 실패 수가 같거나 많은 경우, 아래에 만든(하단 설명) lockCnt() 함수를 실행시켜준다. lockCnt 함수는 데이터베이스를 업데이트시켜주는 로직이 담겨져 있다.
  • 그리고 아직 허용 횟수보다 작을 경우, 아이디와 비밀번호 그리고 총 시도 횟 수를 확인 할 수 있도록 msg에 담는다.
  • InsufficientAuthenticationException의 경우는 아직 정확한 원리를 몰라 다음을 기약한다.
  • 그리고 마지막에 나중에 매개변수로 지정할 redirectURL을 보내준다.
private void lockCnt(String username) {
    userMapper.lock(username);
    System.out.println("잠긴 계정 : " + username);
}

해당 구문은 lockCnt가 5회이상 실행됐을 경우, 해당 계정을 잠그기 위한 비즈니스 로직 수행을 담당하는 함수이다.

위와 같이 구현하여, 로그인 5회이상 실패시 계정이 잠기는 기능을 구현해보았다.
이제 위의 응용을 기반으로 계정을 복구하기 위해서는 본인 확인 절차 등을 진행하는 구문에 대해서 연습을 진행할 예정이다.

 

GitHub - CJH-developer/LoginCustomizing: 로그인시스템 커스터마이징

로그인시스템 커스터마이징. Contribute to CJH-developer/LoginCustomizing development by creating an account on GitHub.

github.com

 

728x90