-
[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.jsdocument.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을 공유하거나 북마크하는 경우 에러 메시지까지 포함될 수 있다. 이 문제를 해결하기 위해 세션을 사용할 수 있다. 이 문제를 해결하고 게시글을 수정해야겠다.
'Spring Framework' 카테고리의 다른 글
[Spring Security] JWT 간단히 실습해보기 (1) 2024.05.19 [Spring Security] OAuth2 소셜 로그인 (세션 방식) (0) 2024.05.17 [게시판 만들기] 5️⃣ 이메일 인증 처리 (1) 2024.05.14 [게시판 만들기] 4️⃣ AJAX를 통한 검증 처리 (1) 2024.05.13 [게시판 만들기] 3️⃣ CSS 적용하기 (1) 2024.05.13