ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • [Spring Security] 로그인 실패 시 계정 잠금
    Spring Framework 2024. 5. 16. 00:11

    https://github.com/jngsngjn/board-study

     

    GitHub - jngsngjn/board-study: 게시판을 만들어 봅시다!

    게시판을 만들어 봅시다! Contribute to jngsngjn/board-study development by creating an account on GitHub.

    github.com


     
    일반적인 웹 사이트에서는 악의적인 사용자가 다른 사용자의 계정으로 로그인 시도하는 것을 막기 위해 일정 횟수만큼 로그인 실패 시 계정을 잠그는 기능을 제공한다. 스프링 시큐리티를 사용해서 계정 잠금 기능을 간단히 구현해 보았다. UserDetails 인터페이스를 활용하는 방법도 있었는데 모두 구현한 뒤 알아버려서.. 다음에는 UserDetails의 메서드를 활용하는 방법으로도 구현해 볼 생각이다.
     

    (1) User 엔티티 수정

    먼저, User 엔티티에 로그인 실패 횟수와 계정 잠김 여부 필드를 추가했다.

    @Data
    @Entity
    public class User {
    
        @Id
        @GeneratedValue(strategy = GenerationType.IDENTITY)
        @Column(name = "user_id")
        private Integer userId;
    
        @Column(name = "login_id", unique = true, nullable = false)
        private String loginId;
    
        @Column(nullable = false)
        private String password;
    
        @Column(nullable = false)
        private String name;
    
        @Column(unique = true, nullable = false)
        private String email;
    
        @Enumerated(EnumType.STRING)
        @Column(nullable = false)
        private Role role;
    
        @Column(name = "failed_attempts", columnDefinition = "INT DEFAULT 0")
        private int failedAttempts;
    
        @Column(columnDefinition = "BOOLEAN DEFAULT false")
        private boolean locked;
    
        // 로그인 실패 횟수 증가
        public void incrementFailedAttempts() {
            this.failedAttempts++;
        }
    }

     

    (2) SecurityConfig 수정

    • 로그인 성공과 실패에 따른 처리를 커스텀하는 작업이 필요한데 이를 위해 AuthenticationSuccessHandler와 AuthenticationFailureHandler를 구현하고 등록해 주어야 한다.
    • successHandler(authenticationSuccessHandler)
    • failureHandler(authenticationFailureHandler)
    @Configuration
    @EnableWebSecurity
    @RequiredArgsConstructor
    public class SecurityConfig {
    
        private final CustomAuthenticationSuccessHandler authenticationSuccessHandler;
        private final CustomAuthenticationFailureHandler authenticationFailureHandler;
    
        @Bean
        public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    
            http.authorizeHttpRequests(auth -> auth
                    .requestMatchers("/", "/index.html", "/css/**", "/js/**").permitAll()
    
                    // 글쓰기, 삭제, 수정 -> 인증된 사용자만
                    .requestMatchers("/boards/write", "/boards/{boardId}/delete", "/boards/{boardId}/edit").authenticated()
    
                    // 게시판 페이지, 특정 게시글, 회원가입 -> 모든 사용자
                    .requestMatchers("/login", "/boards", "/boards/{boardId}", "/register/**", "/send-verification-email",
                            "/verify/**", "/verifyError").permitAll()
    
                    .anyRequest().authenticated()
            );
    
            http.formLogin(auth -> auth
    
                    // 로그인 페이지 경로 (GET)
                    .loginPage("/login")
    
                    // 로그인 처리 경로 (POST)
                    .loginProcessingUrl("/login")
                    .successHandler(authenticationSuccessHandler)
                    .failureHandler(authenticationFailureHandler)
    
                    .permitAll()
            );
    
            http.logout(auth -> auth
                    // 로그아웃 경로 (POST)
                    .logoutUrl("/logout")
                    .logoutSuccessUrl("/boards")
                    .permitAll(false)
            );
    
            return http.build();
        }
    
        @Bean
        public BCryptPasswordEncoder passwordEncoder() {
            return new BCryptPasswordEncoder();
        }
    }

     

    (3) CustomAuthenticationSuccessHandler

    • AuthenticationSuccessHandler를 구현하여 로그인 성공 시 처리를 커스텀한다.
    • 따로 구현하지 않으면 계정이 이미 잠겨있는 경우에 아이디와 비밀번호를 올바르게 입력해도 로그인이 성공해버린다.
    • 로그인 횟수가 5회 미만인 상태에서 로그인 성공 시 로그인 실패 횟수를 초기화한다.
    @Component
    @RequiredArgsConstructor
    public class CustomAuthenticationSuccessHandler implements AuthenticationSuccessHandler {
    
        private final UserRepository userRepository;
    
        @Override
        public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException {
            UserDetails userDetails = (UserDetails) authentication.getPrincipal();
            String loginId = userDetails.getUsername();
            User user = userRepository.findByLoginId(loginId);
    
            // 계정이 잠겨있는 경우
            if (user.isLocked() || user.getFailedAttempts() >= 5) {
                response.sendRedirect("/login?error=locked");
            } else {
                // 계정이 잠겨있지 않은 경우 로그인 성공 처리
                user.setFailedAttempts(0);
                userRepository.save(user);
                response.sendRedirect("/boards");
            }
        }
    }

     

    (4) CustomAuthenticationFailureHandler

    • 로그인 실패 시 간단히 URL에 쿼리 파라미터 형식으로 정보를 넘겨주는 방법을 사용했다. 이를 위해 SimpleUrlAuthenticationFailureHandler를 구현하였다.
    • 특정 조건에 따라 리다이렉션 URL 경로만 설정하고 나머지 처리는 상위 클래스에 위임하는 방식이다.
    • 이제 타임리프와 자바스크립트를 사용하여 쿼리 파라미터에 따라 사용자에게 에러 메시지를 보여줄 수 있다.
    @Component
    @RequiredArgsConstructor
    public class CustomAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
    
        private final UserRepository userRepository;
    
        // 로그인 실패 시
        @Override
        public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws ServletException, IOException {
            String loginId = request.getParameter("username");
            User user = userRepository.findByLoginId(loginId);
    
            // 사용자가 존재하지 않는 경우
            if (user == null) {
                setDefaultFailureUrl("/login?error=notFound");
                super.onAuthenticationFailure(request, response, exception);
                return;
            }
    
            // 사용자가 존재하는 경우
            // 이미 실패 횟수가 5회 이상인 경우
            if (user.getFailedAttempts() >= 5) {
                setDefaultFailureUrl("/login?error=locked");
                super.onAuthenticationFailure(request, response, exception);
                return;
            }
    
            user.incrementFailedAttempts();
            userRepository.save(user);
    
            // 실패 횟수가 5회인 경우
            if (user.getFailedAttempts() == 5) {
    
                user.setLocked(true);
                userRepository.save(user);
                setDefaultFailureUrl("/login?error=locked");
                super.onAuthenticationFailure(request, response, exception);
                return;
            }
    
            // 실패 횟수가 5회 미만인 경우
            int remainingAttempts = 5 - user.getFailedAttempts();
            setDefaultFailureUrl("/login?error=true&remainingAttempts=" + remainingAttempts);
            super.onAuthenticationFailure(request, response, exception);
        }
    }

     

    (5) 로그인 폼

    login.html

    <html lang="ko" xmlns:th="http://www.thymeleaf.org">
    <head>
        <meta charset="UTF-8">
        <title>로그인</title>
        <link rel="stylesheet" type="text/css" href="/css/login.css">
        <script src="/js/login.js"></script>
    </head>
    <body>
    <form id="loginForm" action="/login" method="post">
        <div id="loginError" style="color: red">
            <div th:if="${param.error != null and param.error[0] eq 'true'}">
                비밀번호가 맞지 않습니다. <br>
                <span th:if="${param.remainingAttempts != null}">
                (계정 잠금까지 <span th:text="${param.remainingAttempts[0]}"></span>회 남았습니다.)
                </span>
            </div>
    
            <div th:if="${param.error != null and param.error[0] eq 'locked'}">계정이 잠겨 있습니다. 관리자에게 문의하세요.</div>
            <div th:if="${param.error != null and param.error[0] eq 'notFound'}">등록되지 않은 계정입니다.</div>
        </div>
        <div id="emptyError" style="color: red"></div>
    
        <input type="text" id="username" name="username" placeholder="아이디" autofocus> <br>
        <input type="password" id="password" name="password" placeholder="비밀번호">
        <input type="hidden" name="_csrf" th:value="${_csrf.token}"/> <br>
        <button type="submit">로그인</button>
        <a href="/boards">뒤로 가기</a>
    </form>
    
    </body>
    </html>

     
    login.js

    document.addEventListener('DOMContentLoaded', function() {
        const loginForm = document.getElementById('loginForm');
    
        loginForm.addEventListener('submit', function(event) {
            const loginId = document.getElementById('username').value.trim();
            const password = document.getElementById('password').value.trim();
            const emptyErrorDiv = document.getElementById('emptyError');
            const loginErrorDiv = document.getElementById('loginError');
    
            if (loginId === "" || password === "") {
                event.preventDefault(); // 폼 제출 막기
                emptyErrorDiv.textContent = '아이디와 비밀번호를 입력해 주세요.';
                if (loginErrorDiv) {
                    loginErrorDiv.style.display = 'none';
                }
            }
        });
    });

     


    (6) 남은 문제점

    쿼리 파라미터를 사용하여 에러 메시지가 URL에 노출된다. 이는 사용자에게 혼란을 줄 수 있으며, 사용자가 URL을 공유하거나 북마크하는 경우 에러 메시지까지 포함될 수 있다. 이 문제를 해결하기 위해 세션을 사용할 수 있다. 이 문제를 해결하고 게시글을 수정해야겠다.