서비스 운영시 운영 원칙에 위배되는 이용을 하거나, 비 정상적인 이용을 하는 유저를 제재해야할 필요성이 있습니다.
정지를 하는것엔 여러 부류가 있겠지만, 이 글과 다음 글에서는 아래 두 가지를 구현해보겠습니다.
이번 게시글에선 로그인 실패 횟수를 정해놓고, 그 횟수 이상 로그인을 실패할 경우 나중에 로그인에 성공하더라도 잠기도록 처리하는 것을 Spring-Security와 Spring Data Jpa로 구현하겠습니다.
이를 구현하기 위해서 저는 Spring-Security의
AuthenticationSuccessHandler, AuthenticationFailureHandler를 이용하도록 하겠습니다.
Spring-Security의 formLogin방식에선 로그인에 성공하거나, 실패할경우 위에 각 경우에 해당하는 인터페이스를 실행하는데, 인터페이스 내부는 아래와 같이 구성되어 있습니다.
public interface AuthenticationSuccessHandler {
default void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, FilterChain chain,
Authentication authentication) throws IOException, ServletException {
onAuthenticationSuccess(request, response, authentication);
chain.doFilter(request, response);
}
void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException;
}
public interface AuthenticationFailureHandler {
void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException;
}
SuccessHandler의 경우 로그인에 성공한 뒤의 상황에서 이벤트를 처리하는 것이기 때문에, 메소드의 인자로 Authentication을 전달 받게 되는데, Authentication에는 방금 전 AuthenticationFilter에서 조회한 인증 객체가 들어있기 때문에 따로 핸들러에서 조회할 필요없이 판단하면 되지만,
FailureHandler의 경우는 로그인을 실패한 뒤의 상황이므로 인증과 관련된 객체 자체가 없습니다.
처음 저도 이걸 구현할때 어디서 로그인한 대상에대한 엔티티 조회를 해올지 많이 난감했었는데, 조금 생각해보니
formLogin방식에서 사용한 Email이 위의 메소드 인자로 받은 HttpServletRequest에 들어있을거란 생각이 들었습니다.
Spring-Security에선 이를 기본 파라미터로 "username"을 받는데, 이를 별도로 지정해두지 않으셨다면
request.getParameter("username"); 으로 조회하면 로그인 실패시 입력했던 이메일이나 아이디가 들어있습니다.
이제 본격적으로 구현에 들어가보겠습니다.
- Account Entity
@Entity
@Getter @Setter
@Inheritance(strategy = InheritanceType.JOINED)
@DiscriminatorColumn
@EqualsAndHashCode(of = "id")
@NoArgsConstructor
public abstract class Account {
...
private boolean blocked;
private int failCount;
...
}
- UserDetails or User 클래스 상속한 클래스
@Getter @Setter
@EqualsAndHashCode(of = "id", callSuper = false)
public class UserAccount extends User {
...
private Customer customer;
private StoreOwner storeOwner;
private Rider rider;
...
}
- AuthenticationSuccessHandler 구현 클래스
@Component
@RequiredArgsConstructor
@Transactional
public class AccountLoginSuccessHandler implements AuthenticationSuccessHandler {
private RequestCache requestCache = new HttpSessionRequestCache();
private RedirectStrategy redirectStrategy = new DefaultRedirectStrategy();
private final AccountRepository accountRepository;
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException, ServletException {
UserAccount principal = (UserAccount) authentication.getPrincipal();
Account account;
if(principal.getCustomer() != null){
account = principal.getCustomer();
}else if(principal.getStoreOwner() != null){
account = principal.getStoreOwner();
}else if(principal.getRider() != null){
account = principal.getRider();
}else{
return;
}
// 로그인 5회 실패 전 로그인 성공할경우 실패 횟수 리셋을 위해 엔티티 조회
Account findAccount = accountRepository.findById(account.getId()).get();
if(findAccount.getFailCount() >= 5){
// 아래 예외로 인해 로그인 실패가 발생하고, 로그인 실패 핸들러 호출됨
throw new LockedException("계정이 잠겼습니다. 비밀번호 찾기 후 로그인 해 주세요");
}else{
findAccount.setFailCount(0);
}
SavedRequest savedRequest = requestCache.getRequest(request, response);
// 인증이 필요한 리소스에 접근하려다 로그인 화면으로 넘어간경우
if(savedRequest != null){
redirectStrategy.sendRedirect(request, response, savedRequest.getRedirectUrl());
}else{ // 직접 로그인 페이지로 이동해서 들어온경우 메인페이지로 리다이렉트
redirectStrategy.sendRedirect(request, response, "/");
}
}
}
- AuthenticationFailureHandler 구현 클래스
@Component
@RequiredArgsConstructor
@Transactional
public class AccountLoginFailureHandler implements AuthenticationFailureHandler {
private final AccountRepository accountRepository;
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException {
if(exception instanceof BadCredentialsException) { // 그냥 아이디, 비밀번호가 일치하지 않아서 진입했을경우
String email = request.getParameter("email");
// 로그인 실패 카운트 수정하기 위해 엔티티 조회
Account findAccount = accountRepository.findByEmail(email);
if (findAccount == null) {
throw new IllegalArgumentException("잘못된 요청입니다.");
}
findAccount.addFailCount();
if (findAccount.getFailCount() >= 5) {
request.setAttribute("isLocked", true);
} else {
request.setAttribute("isFailed", true);
}
}else if(exception instanceof LockedException){ // LoginSuccessHandler에서 LockedException발생시 넘어 온 경우
request.setAttribute("isLocked", true);
}
RequestDispatcher requestDispatcher = request.getRequestDispatcher("/login");
requestDispatcher.forward(request, response);
}
}
request에 설정한 속성들은 뷰페이지에서 각 문제 발생에 대해 구분해서 보여주는 용도로 설정 해줬습니다.
복잡해보이긴 하지만 간단하게 원리를 설명하자면
[로그인을 한다]
- 실패했으면 카운트 +1 하고 isFailed 설정 혹은 이미 5이상 카운트 됐으면 request에 isLocked설정후 포워딩
- 성공했을경우 실패카운트 확인하고 5이상 됐으면 LockedException발생
-> FailureHandler진입 후 조건문에 의해 request에 isLocked설정후 포워딩
입니다.
본래 SpringSecurity 없이 처음부터 구현할 경우 이걸 처리하는 것도 꽤 복잡하리라 생각됩니다.
SpringSecurity 자체도 어렵지만 그래도 잘 알아보고 사용한다면 정말 편하게 많은 기능을 이용해볼수 있는 것 같습니다.
조금 더 추가하자면,
- 로그인시 엔티티를 계속 조회해오는 것 자체가 서버에 부담을 줄 수 있기 때문에 세션에 저장해두고 특정 조건에서 로그인 성공시 그 때만 엔티티를 수정하도록 해주는 것,
- 로그인을 과도하게 시도할 시 일정시간 로그인이 불가능하도록 처리하는 방법 등에 대해서도 고민해보면 좋을 듯 싶습니다.
다음 글에선 실제 실시간으로 유저를 로그아웃시키고, 정지까지 시키는 실시간 밴을 구현해보겠습니다.
감사합니다.
잘못된 내용이나 더 나은 방법을 알고 계신 분은 주저하지 마시고 댓글로 공유 부탁드립니다!