조급하면 모래성이 될뿐

로그인 실패시 횟수 증가 및 계정잠김처리, 예외메시지 출력 구현하기 본문

구현 기록/SpringSecurity

로그인 실패시 횟수 증가 및 계정잠김처리, 예외메시지 출력 구현하기

Pawer0223 2020. 2. 12. 11:50

참조 블로그

[ to-dy ]

[ 사랑이 고픈 프로그래머 ]

 

목표

- SpringSeucurity를 활용하여 로그인 실패 시 처리되야하는 프로세스를 추가해보자.

- 로그인 실패 시 실패 횟수를 증가시킨다.

- 5번이상 실패하면 계정을 비활성화시킨다.

- 로그인 실패에 대한 에러 메시지를 session처리 방식에서 request.setAttribute방식으로 변경한다.

- 에러메시지를 하드 코딩하지 말고 별도 properties파일로 관리해보자.

 

작업 과정 기록하기

로그인 실패 시 수행될 프로세스를 정의하기 위해서는 AuthenticationFailureHandler 인터페이스를 구현 후 onAuthenticationFailure() 메서드를 재정의 해주면 된다!

 

아래는 onAuthenticationFailure메서드의 정보이다.

	void onAuthenticationFailure(HttpServletRequest request,
			HttpServletResponse response, AuthenticationException exception)
			throws IOException, ServletException;

 

여기서 3번째 인자 값의 AuthenticationException는 로그인 실패 정보를 가지고 있는 객체이다.

 

request로 요청을 받고, AuthenticationException을 활용해 에러 내용을 설정한 후에 , response로 보내주면 된다.

 

그럼 실패 처리를 위한 코드를 작성해 보자.

 

1. Config설정 변경 ( SecurityConfig.java )

@Autowired
private AuthenticationFailureHandler failureHandler;

...

http
	.formLogin() // 하위에 내가 직접 구현한 로그인 폼, 로그인 성공시 이동 경로 설정 가능. , 로그인 폼의 아이디,패스워드는 username, password로 맞춰야 함
		//.failureUrl("/login?error=true") // 인증에 실패했을 때 보여주는 화면 url, 로그인 form으로 파라미터값 error=true로 보낸다. , failureHandler 사용으로 불필요해졌다.
		.failureHandler(failureHandler)

 

여기서 기존에 사용되던 failureUrl의 기능은 failureHandler에서 재 구현할 것이므로 삭제해도 좋다.

 

그리고 [. failureHandler ]의 인자 값으로 사용된 AuthenticationFailureHandler의 참조 변수는 우리가 새로 만들 클래스이다.

 

그리고 Authowired 되고 있으니 아래 @Bean객체에 대한 코드도 추가해주자.

	  // 실패 처리를 위한 Handler
	  @Bean
	  public AuthenticationFailureHandler failureHandler() {
		  log.info("[ BEAN ] : failureHandler");
		  return new CustomAuthenticationFailureHandler("username", "password" , "loginRedirectUrl" , "exceptionMsgName" , "/login");
	  }

 

먼저 설명하자면 AuthenticationFailureHandler의 인자 값의 의미는 아래와 같다.

 

"username"

- id가 입력되는 input 태그의 name

"password"

- pw가 입력되는 input태그의 name

"loginRedirectUrl

- 로그인 이전 페이지 정보를 담고 있다.

- 로그인 폼에 loginRedirect로 신규 input을 추가할 것이다.

- 이것을 사용하는 이유는 로그인을 하는 시점이 다 다를 수 있기 때문이다.

-  게시판을 검색하다가 세션이 만료돼서 재로그인을 해야 한다면, 재로그인한 후에는 메인 페이지가 아닌 이전에 보고 있던 게시판 페이지를 계속해서 보여주기 위함이다. 

"exceptionMsgName"

- 로그인 페이지에서 jstl을 이용해 에러 메시지를 가져올 때 사용할 변수 이름. ( attribute의 id값으로 사용된다. )

"/login"

- 로그인 실패 시 보여줄 화면 url이다. ( 기존 config의 [. failureUrl ]의 역할 )

 

2. 신규 FailureHandler클래스 추가

 

- AuthenticationFailureHandler 인터페이스를 구현한다.

- 기본 전역 변수들 선언 및 초기화 작업을 위한 생성자를 Override 해준다.

- getter, setter도 추가한다.

- onAuthenticationFailure() 메서드를 Override 한다.

 

- 핵심은 onAuthenticationFailure() 메서드이다.

- 구현할 기능은 첫 번째는 AuthenticationException에 담긴 에러 정보를 받아서 request의 Attribute속성에 추가하여 화면에 출력될 수 있도록 할 것이다.

- 그리고, 에러 정보에 대한 메시지는 MessageSource를 사용하여 properties를 사용한 에러 메시지를 출력할 것이다.

- 두 번째는 로그인 실패한 계정의 실패 횟수를 증가시켜 줄 것이다. ( update )

 

 

2-1) 에러 메시지 출력 변경

 

기존 Session에서 가지고 오던 것을 제거하고, AuthenticationException클래스를 활용하여 request.setAttribute에 에러 메시지를 전달해 줄 것이다.

 

SpringSeucurity에는 로그인 시도 중 발생한 에러에 대해 처리할 수 있는 Exception클래스들이 정의되어있다.

 

그중 대표적인 예외 클래스 몇 개에 대해.. 적절한 에러 메시지를 출력하도록 설정해 줄 것이다.

 

예외의 간단한 내용은 API문서를 참조 후 번역기를 돌렸다.. 자세한 건 [ 링크 ] 참조!

 

- BadCredentialsException

: 자격 증명이 유효하지 않아 인증 요청이 거부된 경우 발생합니다. 이 예외가 발생하면 계정이 잠기거나 비활성화되지 않은 것입니다.

=> 아이디 혹은 비밀번호가 맞지 않습니다.

 

- InternalAuthenticationServiceException

: 내부적으로 발생한 시스템 문제로 인해 인증 요청을 처리할 수 없는 경우 발생합니다. 

=> 내부적으로 발생한 시스템 문제로 인해 인증 요청을 처리할 수없습니다.

 

- DisabledException

: 계정이 비활성화되어 인증 요청이 거부된 경우 발생합니다. 신임 정보가 유효한지 여부에 대해서는 어떠한 주장도 하지 않습니다.

=> 계정이 비활성화 상태입니다. 관리자에게 문의하세요.

 

- CredentialsExpiredException

: 계정의 자격 증명이 만료되어 인증 요청이 거부 된 경우 발생합니다. 신임 정보가 유효한지 여부에 대해서는 어떠한 주장도하지 않습니다.

=> 자격 증명 유효 기간이 만료되었습니다.

 

- UsernameNotFoundException

: UserDetailsService 구현이 사용자 이름으로 사용자를 찾을 수없는 경우 발생합니다.

=> [ {0} ]은(는) 존재하지 않는  ID입니다.

 

아래는 예외 클래스에 따른 에러 메시지를 셋팅해주는 코드이다.

( userUnLock변수를 사용하는 부분은 아래에서 추가 설명. 우선은 errormsg 셋팅 부분만 주목하자. )

설정한 에러메시지를 setAttribute 해서 기본 url로 forward 해주게 된다.

		String errormsg = exception.getMessage();
		
		if(exception instanceof BadCredentialsException) {
			// 잠긴계정인지 확인하여, errormsg변경해준다.
			boolean userUnLock = true;
			userUnLock = failCnt(loginId);
			if ( !userUnLock )
				errormsg = messageSource.getMessage("AccountStatusUserDetailsChecker.disabled", null , Locale.KOREA);
			else
				errormsg = messageSource.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", null , Locale.KOREA);
		} else if(exception instanceof InternalAuthenticationServiceException) {
			errormsg = messageSource.getMessage("AbstractUserDetailsAuthenticationProvider.InternalAuthentication", null , Locale.KOREA); 
		} else if(exception instanceof DisabledException) {
			errormsg = messageSource.getMessage("AccountStatusUserDetailsChecker.disabled", null , Locale.KOREA);
		} else if(exception instanceof CredentialsExpiredException) {
			errormsg = messageSource.getMessage("AccountStatusUserDetailsChecker.expired", null , Locale.KOREA);
		} else if(exception instanceof UsernameNotFoundException) {
			Object[] args = new String[] { loginId } ;
			errormsg = messageSource.getMessage("DigestAuthenticationFilter.usernameNotFound", args , Locale.KOREA);
		} else if(exception instanceof AccountExpiredException) {
			errormsg = messageSource.getMessage("AbstractUserDetailsAuthenticationProvider.expired", null , Locale.KOREA);
		} else if(exception instanceof LockedException) {
			errormsg = messageSource.getMessage("AbstractUserDetailsAuthenticationProvider.locked", null , Locale.KOREA);
		} 
		
		...
        
		request.setAttribute(exceptionMsgName, errormsg);
		
		request.getRequestDispatcher(defaultFailureUrl).forward(request, response);
	}

 

그리고 getMessage에서는 직접 에러를 하드코딩하는 것이 아니라, properties에 설정된 key값으로 가지고 오고 있다. properties설정은 아래와 같이 되어있다.

AbstractAccessDecisionManager.accessDenied = 접근이 거부되었습니다.

 

다음으로 properties를 읽어서 공통 message를 관리할 수 있도록 도움을 주는 MessageSource인터페이스를 활용하여 설정을 추가해주었다.

 

MessageSource설정은 해당 블로그의 글을 참조하였다. [ 링크 ]

 

ㅇ 메시지 소스 생성을 위한 @Configuration기능의 Class추가 [ ContextMessage.java ]

- 주의할 점은 본인 작업환경에 맞추어 경로를 설정해주자.

- 나는 경로를 "classpath:/messages/message"로 지정하였다.

 

ㅇ 언어 변경을 위한 인터셉터를 추가. ( WebMvcConfigurer를 구현한 클래스에 추가로 정의하였다. )

[ WebMvcConfigurer.java ]

 

ㅇ 1번에서 지정해준 경로에 message.properties파일을 추가 후 내용을 추가한다.

- springboot에서 작업 시 classpath경로는 [ src/main/resources ]이다. 

- 위와 같이 properties파일을 추가해 주었고, 내용은 SpringSecurity에서 제공되는 properties파일의 내용을 복사 붙여 넣기 하였다.

( spring-security-core-5.2.1.RELEASE.jar > org.springframework.security > message_ko_KR.properties )

 

그리고 key값은 그대로 유지하고, 에러 메시지만 변경해서 사용할 것이다.

[ message.properties ]

 

properties파일의 한글이 깨진다면 [ 링크 ]를 참고!

 

마지막으로 jsp페이지에서 에러 출력 메시지 정보 받아오는 코드를 수정해주자.

<%--<c:if test="${not empty SPRING_SECURITY_LAST_EXCEPTION}"> --%>
	<c:if test="${not empty exceptionMsgName}" ><br>
	<font color="red">
	<p>${exceptionMsgName}
<%--${sessionScope["SPRING_SECURITY_LAST_EXCEPTION"].message} --%>
	</p> 
	</font>
<%--</c:if> --%>

기존에 session을 통해서 에러 메시지를 출력하던 코드를, 우리가 request.SetAttribute로 설정한 errmsg의 내용을 출력해주도록 변경해 주 었다. ( 기존 코드는 주석 <%-- --%> 처리 )

 

여기서 exceptionMsgName은 Security에서 정해진 값이 아니고, FailUreHandler생성자 호출 시 우리가 넘겨준 인자 값이다. ( 즉, 쓰고 싶은 변수로 지정해서 쓰면 된다. )

 

여기까지 하고 에러 메시지가 제대로 출력되는지 확인해보자.

 

* 존재하지 않는 ID 입력 시

* Password 틀린 경우

 

* isEnable가 false인 경우

2-2) 로그인 실패 횟수 증가시키기.

 

로그인 실패 횟수는 pw나 id가 틀렸을 경우에 증가시켜주면 된다.

 

고로 BadCredentialsException 예외가 발생하였을 때, 증가시켜주도록 할 것이다.

 

그전에 먼저.. 최초 생성한 테이블에는 실패 횟수를 저장할 수 있는 칼럼이 존재하지 않으므로 신규로 추가해 주었다.

alter table user_info add failCnt int;

* Account클래스에도 failCnt값과 관련 getter, setter를 추가해주었다. ( vo클래스 )

 

이제 로그인 시도 실패하면, 실패 횟수를 최대 5까지 증가시킬것이고, 5번실패하면 계정이 잠기도록 구현해보자.

 

고려해야 할 사항은 아래와 같다.

 

기존에 계정이 잠겨있었다면, 실패횟수를 증가시킬 필요가 없다.

( 성공해도 isEnabled=false 로 인해서 로그인을 못한다 )

그래서 에러 메시지 출력하는 부분을 계정이 잠겼다는 메시지로 바꾸어 출력해줄 것이다.

 

계정이 잠겨있지 않다면 실패 횟수를 증가시킨다.

실패 횟수가 5미만이라면 증가시키고, 5이상이 된 경우에는 계정을 잠가준다. ( isEnabled = false설정 )

 

실패횟수 증가는 기존 값 + 1로 계산한다.

( isnull은 Oracle의 NVL함수와 동일한 기능이다, NULL인 경우에 0으로 바꾸어 +1 연산을 정상적으로 수행시키기 위함이다. )

	<update id="failCntUpdate" parameterType="String" >
	UPDATE USER_INFO 
	SET failCnt = isnull(failCnt, 0)+1 
	WHERE ID = #{id}
	</update>

 

계정 잠금을 구분하기 위한 boolean타입 변수로 userUnLock을 선언하였다.

로그인을 시도한 계정의 isEnabled칼럼 값으로 초기화 된다.

 

그렇기 때문에 실패 횟수를 조회할 때 isEnabled칼럼도 함께 가져오도록 했다.

 

*reslutType을 int가 아닌 vo클래스인 Account로 설정.

	<select id="getFailCnt"  parameterType="String" resultType="com.boot.test1.vo.Account">
		SELECT failCnt, isEnabled FROM USER_INFO
		WHERE ID= #{id}
	</select>

 

계정을 비활성화하는 쿼리는 0이면 1로 1이면 0으로 변경하도록 해줄 것이다.

( 참고로 0은 false, 1은 true이다. )

 

그래서 해당 쿼리( changeEnabled )는 반드시 필요한 시점에만 수행되도록 제어해야 한다.

입력값으로 바뀌는것이아니라 기존 상태를 변경하기 때문에... ( 0이면1, 1이면0 )

부적절하게 수행되면.. 계정 잠금 상태가 의도와 다르게 변경될 수 있다..

 

추가로, 계정이 [ 비활성화 -> 활성화 ]로 바뀔때는 동시에 실패횟수도 0으로 초기화 하도록해주었다.

 

이렇게 한 의도는 나중에 계정 잠금을 해제할 때도 같은 쿼리를 사용하기 위해서이다..

( 물론 파라미터를 다르게 주어도 되지만.. 좀 다르게 해보고싶었다.. )

 

Oracle에서 Decode함수를 사용하면 되고, 나는 MS SQL로 작업해서 아래와 같이 구현했다.

기능은 DECODE함수와 동일하다.

	<update id="changeEnabled" parameterType="String" >
		UPDATE [BSHOP_DATA].dbo.USER_INFO 
		SET isEnabled = (CASE isEnabled WHEN 0 THEN 1 ELSE 0 END) ,
		failCnt = (CASE isEnabled WHEN 0 THEN 0 ELSE failCnt END)
		WHERE ID = #{id}
	</update>

 

이제 BadCredentialsException 예외 발생 시 처리될 로직을 추가해주자. [ 전체 소스코드 확인 ]

 

*CustomAuthenticationFailureHandler

if(exception instanceof BadCredentialsException) {
  // 잠긴계정인지 확인하여, errormsg변경해준다.
  boolean userUnLock = true;
  userUnLock = failCnt(loginId);
  if ( !userUnLock )
  errormsg = messageSource.getMessage("AccountStatusUserDetailsChecker.disabled", null , Locale.KOREA);
  else
  errormsg = messageSource.getMessage("AbstractUserDetailsAuthenticationProvider.badCredentials", null , Locale.KOREA);
}

먼저 해당 코드는 계정이 잠김 여부를 확인하여 조건에 맞는 에러 메시지를 설정해주는 코드이다.

 

잠김 여부는 failCnt함수의 return 값으로 확인한다. ( 이 값이, 계정의 isEnabled값이다. )

 

failCnt() 함수도 보자.

private boolean failCnt(String loginId) {

  // 계정이 잠겼으면 추가로 실패횟수 증가시키지않고, true를 return한다.
  boolean userUnLock = true;

  // 실패횟수 select
  Account account = accoutDao.getUserInfo(loginId);
  userUnLock = account.isEnabled();

  // 계정이 활성화 되어있는 경우에만 실패횟수와, Enabled설정을 변경한다.
  // Enabled설정은 실패횟수가 5이상일 때 바뀐다.
  if ( userUnLock ) {
  if( account.getFailCnt() < 5 )
  accoutDao.loginFailCnt(loginId);
  else
  accoutDao.changeEnabled(loginId);
  }
  return userUnLock;
}

- getUserInfo( loginId )를 통해 계정 정보를 조회한다. 로그인 실패 횟수와 계정 활성화 여부를 알 수 있다.

 

- 계정활성화 여부 값으로 userUnLock변수를 초기화한다. 활성화돼있다면 true, 비활성 화면 false이다.

 

- 활성화된 경우에 실패 횟수가 5미만이면 횟수를 증가시킨다.

- 활성화 된 경우에 실패횟수가 5 이상이면 isEnabled값을 true(1)로 변경한다. 

( 사실상 이 경우는 실패횟수가 5가되었을때 최초수행되면 해당코드에서는 더이상  changeEnabled함수가 호출 되지않을것이다.

계정상태가 비활성화 -> 활성화 되는 조건에 맞을 때 해당 함수가 다시 호출되어야 한다. )

 

- 활성화되지 않은 경우(0)에는 false를 return 하게 된다.

 

이제 관련 Dao, Mapper 등을 추가해주면 된다.

 

* AccountMapper

	/*
	 *  실패횟수 update
	 */
	void failCntUpdate(String id);
	/*
	 *  실패횟수, isEnabled 조회.
	 */
	Account getFailCnt(String id);
	
	/* 
	 * 계정 활성화 여부변경, 1이었으면 0으로 0이었으면 1로 바꾼다.
	 * 0은 false, 1은 true이다.
	*/
	void changeEnabled(String id);

* AccountRepository

	public Account getUserInfo(String username) {
		Account account = accountMapper.getFailCnt(username);
		return account;
	}
	
	public void loginFailCnt(String username) {
		accountMapper.failCntUpdate(username);
	}
	
	public void changeEnabled(String username) {
		accountMapper.changeEnabled(username);
	}

* AccountMapper.xml

	<update id="failCntUpdate" parameterType="String" >
	UPDATE USER_INFO 
	SET failCnt = isnull(failCnt, 0)+1 
	WHERE ID = #{id}
	</update>
	
	<select id="getFailCnt"  parameterType="String" resultType="com.boot.test1.vo.Account">
		SELECT failCnt, isEnabled FROM USER_INFO
		WHERE ID= #{id}
	</select>
	
	<update id="changeEnabled" parameterType="String" >
		UPDATE USER_INFO 
		SET isEnabled = (CASE isEnabled WHEN 0 THEN 1 ELSE 0 END) 
		WHERE ID = #{id}
	</update>

 

마지막으로 request에 넘어온 id와 , pw, 그리고 Exception을 통해 설정한 에러 메시지, loginRedirectUrl을 setAttribute에 담아서 defaultFailureUrl로 forward 해주면 된다! ( * forward와 redirect의 차이점 [ 링크 ] )

request.getRequestDispatcher(defaultFailureUrl).forward(request, response);

그리고 아직 loginRedirectUrl은 활용되지 않고 있는데, 일단 무시해도 좋다.

 

추가로 내용을 작성하도록 하겠다.

 

이로써 로그인 실패 시, 계정의 실패 횟수를 증가시키고 5회 이상 실패 시에는 계정을 비활성화 상태로 변경하도록 구현해주었다.

 

또, Session을 활용해 에러 메시지를 출력하던 것을 request.setAttribute로 변경하면서 적절한 에러메시지를 출력할 수 있도록 하였다..

 

아직도 맛보기에 불과하지만.. 조금씩 익숙해지고 있는 것 같다..

 

 

 

반응형