본문으로 바로가기

로그인에 실패했을 때 에러 메시지만 보여줘도 되지만 나는 추가적인 부가 작업을 해보려고 한다. 로그인에 실패했을 때 부가적인 작업을 할 수 있는 인터페이스를 Spring Security 에서 제공한다. 마찬가지로 로그인 실패 작업을 커스터마이징 한다고 생각하면 된다. 물론 필요가 없으면 사용하지 않아도 되지만, 대부분은 부가 작업을 하게 될 것이다. 나는 아래의 3개의 작업을 할 것이다.


1. 로그인 실패시 입력한 로그인 정보 띄우기

2. 세션을 이용하지 않고 에러 메시지 보여주기

3. 비밀번호를 3번 이상 틀릴시 계정 잠금 처리


Spring security 에서 제공하는 AuthenticationFailureHandler 인터페이스를 구현하게 되면 onAuthenticationFailure() 메서드를 오버라이드 받게 된다. 즉, 로그인에 실패하게 되면 해당 메서드가 실행이 되는 것이다. 해당 메서드에서 넘어오는 객체는 3가지가 있다. 해당 객체들을 이용해서 정보를 가져오고 추가작업들을 하면 된다.


HttpServletRequest 객체: 웹에서 넘어온 Request 값을 가지고 있는 객체

HttpServletResponse 객체: 출력을 정의할 수 있는 객체

AuthenticationException 객체: 로그인 실패 정보를 가지고 있는 객체


HttpServletReqeust 객체를 이용해서 웹에서 넘오는 로그인 정보들을 메서드(getParameter)를 이용해서 가져올 수 있다. 이렇게 가져온 값들을 그대로 다시 셋팅(setAttribute)해서 다시 웹으로 넘겨주면 된다. 또한 AuthenticationException 객체를 통해 실패 메시지를 가져와서 다시 셋팅하여 웹으로 보내면 된다. 이렇게 셋팅한 값들을 메서드(getRequestDispatcher)를 통해 보여줄 화면으로 forward 해주면, jstl을 이용해서 셋팅한 값들을 보여줄 수 있다.


먼저 4가지의 파라미터를 설정할 것이다.  우리가 필요한 정보들을 불러올 때 사용하는 파라미터라고 생각하면 된다. 다시말해, 이 파라미터들을 이용해서 각각의 값들을 불러오게 된다. 이렇게 불러온 값들을 샛팅해서 보여줄 페이지에 jstl을 이용해서 보여주면 된다. 


파라미터 설정

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
public class LoginFailureHandler implements AuthenticationFailureHandler {
    
    private String loginidname;
    private String loginpwdname;
    private String errormsgname;
    private String defaultFailureUrl;
 
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
            throws IOException, ServletException {
    }
 
    public String getLoginidname() {
        return loginidname;
    }
 
    public void setLoginidname(String loginidname) {
        this.loginidname = loginidname;
    }
 
    public String getLoginpwdname() {
        return loginpwdname;
    }
 
    public void setLoginpwdname(String loginpwdname) {
        this.loginpwdname = loginpwdname;
    }
 
    public String getErrormsgname() {
        return errormsgname;
    }
 
    public void setErrormsgname(String errormsgname) {
        this.errormsgname = errormsgname;
    }
 
    public String getDefaultFailureUrl() {
        return defaultFailureUrl;
    }
 
    public void setDefaultFailureUrl(String defaultFailureUrl) {
        this.defaultFailureUrl = defaultFailureUrl;
    }
 
}
cs

- 03행: HttpServletRequest 에서 로그인 아이디가 저장되어 있는 파라미터 이름. 아이디값이 들어오는 input 태그 name

- 04행: HttpServletRequest 에서 로그인 비밀번호가 저장되어 있는 파라미터 이름. 비밀번호값이 들어오는 input 태그 name

- 05행: 로그인 페이지에서 jstl을 이용하여 에러메시지를 가져올 때 사용할 변수 이름.

- 06행: 실패시 보여줄 화면 url 

- 13~43행: 파라미터들의 getter/setter이다. getter/setter가 존재하지 않으면 <bean> 등록시 <property> 태그에 오류가 발생한다.


security 설정

context-security.xml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<security:form-login
    username-parameter="loginId"
    password-parameter="loginPwd"
    login-processing-url="/login"
    login-page="/secu/loginPage"
    default-target-url="/"
    authentication-failure-handler-ref="loginFailureHandler"
/>
 
<bean id="loginFailureHandler" class="tody.common.handler.LoginFailureHandler">
    <property name="loginidname" value="loginId"/>
    <property name="loginpwdname" value="loginPwd"/>
    <property name="errormsgname" value="ERRORMSG"/>
    <property name="defaultFailureUrl" value="/secu/loginPage?error"/>
</bean>
cs

- 07행: authentication-failure-handler-ref 속성에 <bean>태그로 등록한 값을 설정한다. authenticaton-failure-url 속성을 사용해서 로그인에 실패했을 때 호출할 url을 설정했다면, authentication-failure-url 속성이 필요가 없어진다. 우리가 앞서 설정한 파라미터 중 defaultFailureUrl 이 해당 역할을 해줄 것이다.

- 10행: AuthenticationFailureHandler 인터페이스를 구현한 클래스를 <bean> 태그를 이용해 등록한다.

- 11~14행: 4가지의 파라미터 값들을 셋팅해주었다. 이것은 웹에서 넘어온 input 태그의 이름, 혹은 지정한 url 페이지이다.



1. 로그인 실패시 입력한 로그인 정보 띄우기

HttpServletRequest 객체의 getParameter 메서드를 이용하여 사용자가 입력한 로그인 아이디와 비밀번호를 가져오면 된다. 아이디와 비밀번호 input 태그의 name을 이용해거 가져오면 된다. 앞서 정의한 파라미터들을 이용하면 된다.


LoginFailureHandler.java

1
2
3
4
5
6
7
8
9
10
11
12
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
            throws IOException, ServletException {
        
        String username = request.getParameter(loginidname);
        String password = request.getParameter(loginpwdname);
        
        request.setAttribute(loginidname, username);
        request.setAttribute(loginpwdname, password);
 
        request.getRequestDispatcher(defaultFailureUrl).forward(request, response);
    }
cs

- 05, 06행: 아이디와 비밀번호를 getParameter 메서드를 이용해 가져온다.

- 08, 09행: input 태그의 name에 가져온 아이디와 비밀번호 정보를 HttpServletRequest 에서 제공하는 메서드(setAttribute)로 셋팅해준다.

- 11행: HttpServletRequest 의 getRequestDispatcher 메서드를 이용해서 보여줄 화면으로 forward 해준다. forward를 해줘야만 jstl을 이용해서 값들을 가져올 수 있다. 여기서 보여줄 화면은 로그인 실패 시 이동할 페이지(defaultFailureUrl) 이라고 생각하면 된다.



2. 세션을 이용하지 않고 에러 메시지 보여주기

지금까지의 에러메시지는 웹에서 세션에 접근해서 메시지를 보여주었다. 에러 메세지를 세션에 저장하는 것은 spring security 에서 자동으로 하는 것이다. 하지만 이렇게 웹에서 세션을 통해 에러 메시지를 가져오는 건 좋지 않다. 그래서 request의 setAttribute를 이용해서 에러 메시지를 보여줄 것이다. 헷갈릴 수도 있어서 더 설명하자면, 웹에서 세션을 통해 에러메시지를 가져오는 걸 막기 위해서 세션을 통해 가져온 에러메시지를 Attribute를 이용해 저장한 다음, 웹에서는 jstl로 보여주는 작업을 하는 것이다.


LoginFailureHandler.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
            throws IOException, ServletException {
        
        String username = request.getParameter(loginidname);
        String password = request.getParameter(loginpwdname);
        String errormsg = exception.getMessage();
        
        request.setAttribute(loginidname, username);
        request.setAttribute(loginpwdname, password);
        request.setAttribute(errormsgname, errormsg);
 
        request.getRequestDispatcher(defaultFailureUrl).forward(request, response);
    }
cs

- 07행: 세션을 통해 에러메시지를 가져온다.

- 11행: 에러 메시지를 셋팅해준다.


사실 세션을 통해 에러메시지를 가져와서 다른 값에 셋팅해주어 보여주기만 한다면 이렇게 간단하다. 이렇게 해도 별 문제는 없다. 하지만 나는 추가 작업이 더 필요하다. 내가 쉬운 방법을 찾지 못한건지 모르겠지만, 이 다음 작업인 비밀번호가 틀렸을 때 해주어야할 작업이 남아있는데, 이 작업을 위해서는 아이디가 틀린 경우, 비밀번호가 틀린 경우를 구분해줘야 한다. (사실 이 부분부터는 내가 생각한 방식이여서 정확한 방법이 아닐 수도 있다.) 


먼저 메세지 프로퍼티 사용하기 위해서 Message Source Accessor를 등록해줘야한다. 별로 많은 내용이 있지는 않지만 따로 분리해서 설명을 하려고 한다. 메세지 프로퍼티를 사용할 준비가 됐다면 에러를 분리해보자. 내가 찾은 spring security의 예외 세션들이다.


 Exception

 설명

 BadCredentialException

 비밀번호가 일치하지 않을 때 던지는 예외

 InternalAuthenticationServiceException

 존재하지 않는 아이디일 때 던지는 예외

 AuthenticationCredentialNotFoundException

 인증 요구가 거부됐을 때 던지는 예외

 LockedException

 인증 거부 - 잠긴 계정

 DisabledException

 인증 거부 - 계정 비활성화

 AccountExpiredException

 인증 거부 - 계정 유효기간 만료

 CredentialExpiredException

 인증 거부 - 비밀번호 유효기간 만료


여기서 알아둬야 할 것은, 비밀번호와 아이디가 일치하지 않는 예외들의 메세지를 분리해서는 안된다. 물론 분리해서 아이디가 틀렸습니다, 혹은 비밀번호가 틀렸습니다와 같은 메세지를 보여줘도 된다. 하지만 이러면 보안성이 낮아진다. 조금이라도 더 보안성을 높이려면 두 개의 예외를 하나의 메세지로 처리하는 것이 좋다.


예외 분리

1
2
3
4
5
6
7
8
9
//AuthenticationProvider 인터페이스를 구현한 클래스의 Authentication()
if(!user.isEnabled() || !user.isCredentialsNonExpired()) {
    throw new AuthenticationCredentialsNotFoundException(username);
}
 
//UserDetailsService 인터페이스를 구현한 클래스의 loadUserByUsername() 메서드
if(user==null) {
    throw new InternalAuthenticationServiceException(username);
}
cs


계정 인증 관련 예외들을 BadCredentialException 예외로 던지면 세션에서 알아서 세분류 되어 에러 메시지를 보여주었다. 마찬가지로 좀 더 세분화하기 위해 인증과 관련된 예외중 상위 예외인 AuthenticationCredentialNotFoundException 예외를 사용했다. 해당 예외로 던지면 핸들러에 오는 에러 세션는 좀 더 세분화 된 예외로 들어오게 된다. 그렇기에 or 조건을 사용하여 인증 관련 예외들은 하나로 묶은 뒤 해당 예외를 던져주었다.


계정이 존재하지 않을 때 UserNotFoundException 예외를 던지게 되면, BadCredentialException 예외로 받게 된다. 그러면 아이디 예외와 비밀번호 예외가 구분되지 않아서, 찾다가 찾은 예외가 InternalAuthenticationServcieException 예외이다. 해당 예외는 UserDetailsService 인터페이스에서 아무런 값이 리턴되지 않을 때 발생하는 예외이다. 리턴값이 아무것도 없다는 것은 아이디가 존재하지 않다는 뜻이라 생각해서 해당 예외를 사용하게 되었다.


LoginFailureHandler.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
    @Override
    public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception)
            throws IOException, ServletException {
        
        String username = request.getParameter(loginidname);
        String password = request.getParameter(loginpwdname);
        String errormsg = null;
        
        if(exception instanceof BadCredentialsException) {
            errormsg = MessageUtils.getMessage("error.BadCredentials");
        } else if(exception instanceof InternalAuthenticationServiceException) {
            errormsg = MessageUtils.getMessage("error.BadCredentials");
        } else if(exception instanceof DisabledException) {
            errormsg = MessageUtils.getMessage("error.Disaled");
        } else if(exception instanceof CredentialsExpiredException) {
            errormsg = MessageUtils.getMessage("error.CredentialsExpired");
        }
        
        request.setAttribute(loginidname, username);
        request.setAttribute(loginpwdname, password);
        request.setAttribute(errormsgname, errormsg);
 
        request.getRequestDispatcher(defaultFailureUrl).forward(request, response);
    }
cs

- 07행: 에러 메시지를 담을 변수를 지정한다.

- 09~17행: AuthenticationException 에서 오는 예외의 타입을 체크해서 알맞은 에러 메시지를 메세지 프로퍼티를 이용해 변수에 담아준다. 비밀번호가 틀릴 경우와 아이디가 없을 경우의 예외가 서로 다르지만, 같은 에러 메시지를 뿌려준다. 

- 21행: 저장된 에러 메시지를 셋팅해준다.


security_message.properties

1
2
3
4
5
error.BadCredentials=아이디나 비밀번호가 맞지 않습니다. 다시 확인해주세요.
error.Disaled=계정이 비활성화되었습니다. 관리자에게 문의하세요.
error.CredentialsExpired=비밀번호 유효기간이 만료 되었습니다. 관리자에게 문의하세요.
error.Locked=계정이 잠겨있습니다. 관리자에게 문의하세요.
error.AccountExpired=계정이 만료되었습니다. 관리자에게 문의하세요.
cs

에러에 맞게끔 예외 메세지를 작성해준다. 내가 사용하고 싶은 key값들로 바뀌었을 뿐 메세지에는 크게 변화는 없다.



3. 비밀번호를 3번 이상 틀릴 시 계정 잠금 처리

비밀번호가 일치하지 않는 예외일 때 해당 로직을 작성해주면 된다. 우리가 예외를 분리한 이유이다. 비밀번호 틀린 횟수를 어떻게 가지고 있어야 할까? DB를 이용해서 틀린 횟수를 체크하려고 한다. DB에 실패 카운터 컬럼을 생성해서 비밀번호가 틀릴 경우 실패 카운터 컬럼을 1씩 증가시킨다면 틀린 횟수를 쉽게 알 수 있게 된다. 


1. 비밀번호 실패 카운터 1 증가

2. 비밀번호 실패 횟수 가져오기

3. 만약 3번 틀렸다면 계정 잠금 처리


비밀번호가 틀렸으면 먼저 실패 카운터를 1 증가시킨 후, 실패 횟수를 가져온다. 실패 횟수가 3번이면 계정 잠금 처리를 하고 아니면 그냥 넘어간다. 이렇게 하면 3번째에 틀렸을 경우 계정이 잠금이 되고, 4번째에 비밀번호가 맞든 틀리든 계정이 잠금되었다는 에러 메시지가 나갈 것이다.


LoginFailureHandler.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public class LoginFailureHandler implements AuthenticationFailureHandler {
 
    @Resource(name="userSer")
    private UserService userSer;
 
    ...
 
    @Override
     public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
                    AuthenticationException exception) throws IOException, ServletException {    
         ...
 
        if(exception instanceof BadCredentialsException) {
            loginFailureCount(username);
            errormsg = MessageUtils.getMessage("error.BadCredentials");
        }
        ...
    }
 
    protected void loginFailureCount(String username) {
        userSer.countFailure(username);
        int cnt = userSer.checkFailureCount(username);
        if(cnt==3) {
            userSer.disabledUsername(username);
        }
    }
    
    ...
}
cs

- 14행: 비밀번호가 틀렸을 경우 실패 카운터와 관련된 메서드를 실행시킨다.

- 20행: 실패 카운터 메서드이다. 로그인 실패 핸들러 안에 존재한다.

- 21행: 실패 카운터를 증가시킨다.

- 22행: 실패 횟수를 가져온다.

- 23~25행: 실패 횟수가 3회일때 계정을 잠금 처리한다.


Service 영역

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public interface UserService {
 
    void countFailure(String username);
 
    int checkFailureCount(String username);
 
    void disabledUsername(String username);
 
}
 
@Service("userSer")
public class UserServiceImpl implements UserService {
    
    @Resource(name="userAuthDAO")
    private UserAuthDAO userDAO;
 
    @Override
    public void countFailure(String username) {
        userDAO.updateFailureCount(username);
    }
 
    @Override
    public int checkFailureCount(String username) {
        return userDAO.checkFailureCount(username);
    }
 
    @Override
    public void disabledUsername(String username) {
        userDAO.updateDisabled(username);
    }
 
}
cs


DAO 영역

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@Repository("userAuthDAO")
public class UserAuthDAO {
    
    @Autowired
    private SqlSessionTemplate sqlSession;
 
    public CustomUserDetails getUserById(String username) {
        return sqlSession.selectOne("user.selectUserById", username);
    }
 
    public void updateFailureCount(String username) {
        sqlSession.update("user.updateFailureCount", username);
    }
    
    public int checkFailureCount(String username) {
        return sqlSession.selectOne("user.checkFailureCount", username);
    }
 
}
cs


SQL 영역

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<mapper namespace="user">
 
     <select id="selectUserById" resultType="tody.common.vo.CustomUserDetails">
        <![CDATA[
            SELECT
                *
            FROM
                user
            WHERE
                ID=#{ loginId}
        ]]>
    </select>
    
    <update id="updateFailureCount">
        <![CDATA[
            UPDATE
                user
            SET
                FAILURE_CNT = FAILURE_CNT + 1
            WHERE
                ID = #{ loginId}
        ]]>
    </update>
    
        <select id="checkFailureCount" resultType="Integer">
        <![CDATA[
            SELECT
                FAILURE_CNT
            FROM
                user
            WHERE
                ID=#{ loginId}
        ]]>
    </select>
    
 </mapper>
cs



4. jstl 적용

LoginFailureHandler 클래스에 로그인 정보, 에러 메시지 등을 셋팅하고 로그인 화면으로 forward 해줬다면 이제 jstl을 이용해서 셋팅한 값들을 그대로 보여주면 된다. 주의해야 할 점은 jstl 파라미터 이름을 정확하게 입력해주어야 한다. 그거만 조심하면 문제없이 나올 것이다.


loginPage.jsp

1
2
3
4
5
6
7
8
9
10
11
12
13
14
<form action="/login" method="post">
    ...
        <input type="text" name="loginId" placeholder="example" value="${loginId }">
    ...
        <input type="password" name="loginPwd" placeholder="Password" value="${loginPwd }">
    ...
    <c:if test="${not empty ERRORMSG}">
        <font color="red">
        <p>Your login attempt was not successful due to <br/>
        ${ERRORMSG }</p>
        </font>
    </c:if>
    ...
</form>
cs

- 03, 05행: 사용자가 입력한 로그인 정보를 셋팅하여 jstl을 이용해서 보여준다.

- 07, 10행: 이젠 세션에서 에러메시지를 가져오는 것이 아닌 jstl을 이용해서 에러 메세지를 보여준다.

그리고 에러메시지를 더이상 세션에서 가져오는 것이 아니기 때문에 <c:remove> 태그를 이용해 세션을 지우던 로직은 삭제하였다.



로그인 성공 후 부가 작업부터 하지 않고 실패 핸들러 작업부터 한 이유는, 비밀번호를 2번 틀리고 로그인에 성공했을 때 로그인 횟수가 남아있다면 다음번에 로그인을 할 때 한번이라도 틀리면 계정이 잠금된다. 그걸 방지하기 위해 로그인 성공시 로그인 실패 카운터를 초기화 해주는 작업을 해야 하는데 로그인 성공 핸들러부터 한다면 뭔가 거꾸로 가는 기분이라서 실패 핸들러부터 작성했다.


GitHub

참고 : 사랑이 고픈 프로그래머..