春のセキュリティ:ログイン試行の制限の例

Spring Security:ログイン試行の制限の例

spring-security-limit-login-attempts-locked

このチュートリアルでは、Spring Securityのログイン試行を制限する方法を示します。つまり、ユーザーが無効なパスワードで3回以上ログインしようとすると、システムはユーザーをロックし、ログインできなくなります。

使用される技術とツール:

  1. Spring 3.2.8.RELEASE

  2. Spring Security 3.2.3.RELEASE

  3. Spring JDBC 3.2.3.RELEASE

  4. Eclipse 4.2

  5. JDK 1.6

  6. メーベン3

  7. MySQLサーバー5.6

  8. Tomcat 7(サーブレット3.x)

このチュートリアルの簡単なメモ:

  1. MySQLデータベースが使用されます。

  2. これは、Spring Securityの注釈ベースの例です。

  3. 列「accountNonLocked」を持つ「users」テーブルを作成します。

  4. 「user_attempts」テーブルを作成して、無効なログイン試行を保存します。

  5. Spring JDBCが使用されます。

  6. 返された例外に基づいてカスタムエラーメッセージを表示します。

  7. カスタマイズされた「authenticationProvider」を作成する

1. 溶液

既存のSpring Securityの認証クラスを確認してください。「ロック」機能は既に実装されています。 ログイン試行の制限を有効にするには、UserDetails.isAccountNonLockedをfalseに設定する必要があります。

DaoAuthenticationProvider.java

package org.springframework.security.authentication.dao;

public class DaoAuthenticationProvider
    extends AbstractUserDetailsAuthenticationProvider {
    //...
}

AbstractUserDetailsAuthenticationProvider.java

package org.springframework.security.authentication.dao;

public abstract class AbstractUserDetailsAuthenticationProvider
    implements AuthenticationProvider, InitializingBean,
    MessageSourceAware {

    private class DefaultPreAuthenticationChecks implements UserDetailsChecker {
        public void check(UserDetails user) {
          if (!user.isAccountNonLocked()) {
              logger.debug("User account is locked");

          throw new LockedException(
              messages.getMessage("AbstractUserDetailsAuthenticationProvider.locked",
                 "User account is locked"), user);
          }
           //...
        }
    }

2. プロジェクトのデモ

3. プロジェクトディレクトリ

最終的なプロジェクト構造を確認します(注釈ベース):

spring-security-limit-login-attempts-directory

4. データベース

以下は、ユーザー、user_rolesおよびuser_attemptsテーブルを作成するMySQLスクリプトです。

4.1 Create a “users” table, with column “accountNonLocked”.

users.sql

CREATE  TABLE users (
  username VARCHAR(45) NOT NULL ,
  password VARCHAR(45) NOT NULL ,
  enabled TINYINT NOT NULL DEFAULT 1 ,
  accountNonExpired TINYINT NOT NULL DEFAULT 1 ,
  accountNonLocked TINYINT NOT NULL DEFAULT 1 ,
  credentialsNonExpired TINYINT NOT NULL DEFAULT 1,
  PRIMARY KEY (username));

4.2 Create a “user_roles” table.

user_roles.sql

CREATE TABLE user_roles (
  user_role_id int(11) NOT NULL AUTO_INCREMENT,
  username varchar(45) NOT NULL,
  role varchar(45) NOT NULL,
  PRIMARY KEY (user_role_id),
  UNIQUE KEY uni_username_role (role,username),
  KEY fk_username_idx (username),
  CONSTRAINT fk_username FOREIGN KEY (username) REFERENCES users (username));

4.3 Create a “user_attempts” table.

user_attempts.sql

CREATE TABLE user_attempts (
  id int(11) NOT NULL AUTO_INCREMENT,
  username varchar(45) NOT NULL,
  attempts varchar(45) NOT NULL,
  lastModified datetime NOT NULL,
  PRIMARY KEY (id)
);

4.4 Inserts an user for testing.

INSERT INTO users(username,password,enabled)
VALUES ('example','123456', true);

INSERT INTO user_roles (username, role)
VALUES ('example', 'ROLE_USER');
INSERT INTO user_roles (username, role)
VALUES ('example', 'ROLE_ADMIN');

5. UserAttemptsクラス

このクラスは、「user_attempts」テーブルのデータを表します。

UserAttempts.java

package com.example.users.model;

import java.util.Date;

public class UserAttempts {

    private int id;
    private String username;
    private int attempts;
    private Date lastModified;

    //getter and setter

}

6. DAOクラス

無効なログイン試行を更新するDAOクラスは、一目瞭然のコメントを読みます。

UserDetailsDao.java

package com.example.users.dao;

import com.example.users.model.UserAttempts;

public interface UserDetailsDao {

    void updateFailAttempts(String username);
    void resetFailAttempts(String username);
    UserAttempts getUserAttempts(String username);

}

UserDetailsDaoImpl.java

package com.example.users.dao;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.Date;

import javax.annotation.PostConstruct;
import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.EmptyResultDataAccessException;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.jdbc.core.support.JdbcDaoSupport;
import org.springframework.security.authentication.LockedException;
import org.springframework.stereotype.Repository;

import com.example.users.model.UserAttempts;

@Repository
public class UserDetailsDaoImpl extends JdbcDaoSupport implements UserDetailsDao {

    private static final String SQL_USERS_UPDATE_LOCKED = "UPDATE USERS SET accountNonLocked = ? WHERE username = ?";
    private static final String SQL_USERS_COUNT = "SELECT count(*) FROM USERS WHERE username = ?";

    private static final String SQL_USER_ATTEMPTS_GET = "SELECT * FROM USER_ATTEMPTS WHERE username = ?";
    private static final String SQL_USER_ATTEMPTS_INSERT = "INSERT INTO USER_ATTEMPTS (USERNAME, ATTEMPTS, LASTMODIFIED) VALUES(?,?,?)";
    private static final String SQL_USER_ATTEMPTS_UPDATE_ATTEMPTS = "UPDATE USER_ATTEMPTS SET attempts = attempts + 1, lastmodified = ? WHERE username = ?";
    private static final String SQL_USER_ATTEMPTS_RESET_ATTEMPTS = "UPDATE USER_ATTEMPTS SET attempts = 0, lastmodified = null WHERE username = ?";


    private static final int MAX_ATTEMPTS = 3;

    @Autowired
    private DataSource dataSource;

    @PostConstruct
    private void initialize() {
        setDataSource(dataSource);
    }

    @Override
    public void updateFailAttempts(String username) {

      UserAttempts user = getUserAttempts(username);
      if (user == null) {
        if (isUserExists(username)) {
            // if no record, insert a new
            getJdbcTemplate().update(SQL_USER_ATTEMPTS_INSERT, new Object[] { username, 1, new Date() });
        }
      } else {

        if (isUserExists(username)) {
            // update attempts count, +1
            getJdbcTemplate().update(SQL_USER_ATTEMPTS_UPDATE_ATTEMPTS, new Object[] { new Date(), username});
        }

        if (user.getAttempts() + 1 >= MAX_ATTEMPTS) {
            // locked user
            getJdbcTemplate().update(SQL_USERS_UPDATE_LOCKED, new Object[] { false, username });
            // throw exception
            throw new LockedException("User Account is locked!");
        }

      }

    }

    @Override
    public UserAttempts getUserAttempts(String username) {

      try {

        UserAttempts userAttempts = getJdbcTemplate().queryForObject(SQL_USER_ATTEMPTS_GET,
            new Object[] { username }, new RowMapper() {
            public UserAttempts mapRow(ResultSet rs, int rowNum) throws SQLException {

                UserAttempts user = new UserAttempts();
                user.setId(rs.getInt("id"));
                user.setUsername(rs.getString("username"));
                user.setAttempts(rs.getInt("attempts"));
                user.setLastModified(rs.getDate("lastModified"));

                return user;
            }

        });
        return userAttempts;

      } catch (EmptyResultDataAccessException e) {
        return null;
      }

    }

    @Override
    public void resetFailAttempts(String username) {

      getJdbcTemplate().update(
             SQL_USER_ATTEMPTS_RESET_ATTEMPTS, new Object[] { username });

    }

    private boolean isUserExists(String username) {

      boolean result = false;

      int count = getJdbcTemplate().queryForObject(
                            SQL_USERS_COUNT, new Object[] { username }, Integer.class);
      if (count > 0) {
        result = true;
      }

      return result;
    }

}

7. UserDetailsS​​ervice

デフォルトでは、JdbcDaoImplは常にaccountNonLockedをtrueに設定しますが、これは私たちが望んでいることではありません。 ソースコードを確認します。

JdbcDaoImpl.java

package org.springframework.security.core.userdetails.jdbc;

public class JdbcDaoImpl extends JdbcDaoSupport implements UserDetailsService {
  //...
  protected List loadUsersByUsername(String username) {
    return getJdbcTemplate().query(usersByUsernameQuery, new String[] {username}, new RowMapper() {
      public UserDetails mapRow(ResultSet rs, int rowNum) throws SQLException {
        String username = rs.getString(1);
        String password = rs.getString(2);
        boolean enabled = rs.getBoolean(3);
        return new User(username, password, enabled, true, true, true, AuthorityUtils.NO_AUTHORITIES);
      }

  });
}

開発時間を節約するために、JdbcDaoImplを拡張し、loadUsersByUsernamecreateUserDetailsの両方をオーバーライドして、カスタマイズされたUserDetailsを取得できます。

CustomUserDetailsService.java

package com.example.users.service;

import java.sql.ResultSet;
import java.sql.SQLException;
import java.util.List;

import javax.annotation.PostConstruct;
import javax.sql.DataSource;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.jdbc.core.RowMapper;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.jdbc.JdbcDaoImpl;
import org.springframework.stereotype.Service;

/**
 * Reference org.springframework.security.core.userdetails.jdbc.JdbcDaoImpl
 *
 * @author example
 *
 */
@Service("userDetailsService")
public class CustomUserDetailsService extends JdbcDaoImpl {

    @Autowired
    private DataSource dataSource;

    @PostConstruct
    private void initialize() {
        setDataSource(dataSource);
    }

    @Override
    @Value("select * from users where username = ?")
    public void setUsersByUsernameQuery(String usersByUsernameQueryString) {
        super.setUsersByUsernameQuery(usersByUsernameQueryString);
    }

    @Override
    @Value("select username, role from user_roles where username =?")
    public void setAuthoritiesByUsernameQuery(String queryString) {
        super.setAuthoritiesByUsernameQuery(queryString);
    }

    //override to get accountNonLocked
    @Override
    public List loadUsersByUsername(String username) {
      return getJdbcTemplate().query(super.getUsersByUsernameQuery(), new String[] { username },
        new RowMapper() {
          public UserDetails mapRow(ResultSet rs, int rowNum) throws SQLException {
            String username = rs.getString("username");
            String password = rs.getString("password");
            boolean enabled = rs.getBoolean("enabled");
            boolean accountNonExpired = rs.getBoolean("accountNonExpired");
            boolean credentialsNonExpired = rs.getBoolean("credentialsNonExpired");
            boolean accountNonLocked = rs.getBoolean("accountNonLocked");

            return new User(username, password, enabled, accountNonExpired, credentialsNonExpired,
                accountNonLocked, AuthorityUtils.NO_AUTHORITIES);
          }

      });
    }

    //override to pass accountNonLocked
    @Override
    public UserDetails createUserDetails(String username, UserDetails userFromUserQuery,
            List combinedAuthorities) {
        String returnUsername = userFromUserQuery.getUsername();

        if (super.isUsernameBasedPrimaryKey()) {
          returnUsername = username;
        }

        return new User(returnUsername, userFromUserQuery.getPassword(),
                       userFromUserQuery.isEnabled(),
               userFromUserQuery.isAccountNonExpired(),
                       userFromUserQuery.isCredentialsNonExpired(),
            userFromUserQuery.isAccountNonLocked(), combinedAuthorities);
    }

}

8. DaoAuthenticationProvider

カスタム認証プロバイダーを作成します。無効なログイン試行ごとに、user_attemptsテーブルを更新し、最大試行回数に達した場合はLockedExceptionをスローします。

LimitLoginAuthenticationProvider.java

package com.example.web.handler;

import java.util.Date;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.stereotype.Component;

import com.example.users.dao.UserDetailsDao;
import com.example.users.model.UserAttempts;

@Component("authenticationProvider")
public class LimitLoginAuthenticationProvider extends DaoAuthenticationProvider {

    @Autowired
    UserDetailsDao userDetailsDao;

    @Autowired
    @Qualifier("userDetailsService")
    @Override
    public void setUserDetailsService(UserDetailsService userDetailsService) {
        super.setUserDetailsService(userDetailsService);
    }

    @Override
    public Authentication authenticate(Authentication authentication)
          throws AuthenticationException {

      try {

        Authentication auth = super.authenticate(authentication);

        //if reach here, means login success, else an exception will be thrown
        //reset the user_attempts
        userDetailsDao.resetFailAttempts(authentication.getName());

        return auth;

      } catch (BadCredentialsException e) {

        //invalid login, update to user_attempts
        userDetailsDao.updateFailAttempts(authentication.getName());
        throw e;

      } catch (LockedException e){

        //this user is locked!
        String error = "";
        UserAttempts userAttempts =
                    userDetailsDao.getUserAttempts(authentication.getName());

               if(userAttempts!=null){
            Date lastAttempts = userAttempts.getLastModified();
            error = "User account is locked! 

Username : " + authentication.getName() + "
Last Attempts : " + lastAttempts; }else{ error = e.getMessage(); } throw new LockedException(error); } } }

9. スプリングコントローラー

標準のコントローラークラス。loginメソッドを参照してください。セッション値「SPRING_SECURITY_LAST_EXCEPTION」を試して、エラーメッセージをカスタマイズする方法を示します。

MainController.java

package com.example.web.controller;

import javax.servlet.http.HttpServletRequest;

import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.LockedException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.servlet.ModelAndView;

@Controller
public class MainController {

    @RequestMapping(value = { "/", "/welcome**" }, method = RequestMethod.GET)
    public ModelAndView defaultPage() {

        ModelAndView model = new ModelAndView();
        model.addObject("title", "Spring Security Limit Login - Annotation");
        model.addObject("message", "This is default page!");
        model.setViewName("hello");
        return model;

    }

    @RequestMapping(value = "/admin**", method = RequestMethod.GET)
    public ModelAndView adminPage() {

        ModelAndView model = new ModelAndView();
        model.addObject("title", "Spring Security Limit Login - Annotation");
        model.addObject("message", "This page is for ROLE_ADMIN only!");
        model.setViewName("admin");

        return model;

    }

    @RequestMapping(value = "/login", method = RequestMethod.GET)
    public ModelAndView login(
                @RequestParam(value = "error", required = false) String error,
        @RequestParam(value = "logout", required = false) String logout,
                HttpServletRequest request) {

        ModelAndView model = new ModelAndView();
        if (error != null) {
            model.addObject("error",
                           getErrorMessage(request, "SPRING_SECURITY_LAST_EXCEPTION"));
        }

        if (logout != null) {
            model.addObject("msg", "You've been logged out successfully.");
        }
        model.setViewName("login");

        return model;

    }

    //customize the error message
    private String getErrorMessage(HttpServletRequest request, String key){

        Exception exception =
                   (Exception) request.getSession().getAttribute(key);

        String error = "";
        if (exception instanceof BadCredentialsException) {
            error = "Invalid username and password!";
        }else if(exception instanceof LockedException) {
            error = exception.getMessage();
        }else{
            error = "Invalid username and password!";
        }

        return error;
    }

    // for 403 access denied page
    @RequestMapping(value = "/403", method = RequestMethod.GET)
    public ModelAndView accesssDenied() {

        ModelAndView model = new ModelAndView();

        // check if user is login
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        if (!(auth instanceof AnonymousAuthenticationToken)) {
            UserDetails userDetail = (UserDetails) auth.getPrincipal();
            System.out.println(userDetail);

            model.addObject("username", userDetail.getUsername());

        }

        model.setViewName("403");
        return model;

    }

}

10. Spring Securityの構成

カスタマイズしたauthenticationProviderを添付しました。

SecurityConfig.java

package com.example.config;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Autowired
    @Qualifier("authenticationProvider")
    AuthenticationProvider authenticationProvider;

    @Autowired
    public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
        auth.authenticationProvider(authenticationProvider);
    }

    @Override
    protected void configure(HttpSecurity http) throws Exception {

        http.authorizeRequests().antMatchers("/admin/**")
          .access("hasRole('ROLE_USER')").and().formLogin()
          .loginPage("/login").failureUrl("/login?error")
            .usernameParameter("username")
            .passwordParameter("password")
          .and().logout().logoutSuccessUrl("/login?logout").and().csrf();
    }
}

完了しました。

11. Demo

11.1, First invalid login attempts, a normal error message will be displayed.

spring-security-limit-login-attempts-1

11.2, If the maximum number of invalid login attempts are hit, error message “User account is locked” will be displayed.

spring-security-limit-login-attempts-locked

11.3, If user in “locked” status, and still try to login again. ロックされた詳細が表示されます。

spring-security-limit-login-attempts-locked-detail

11.4 Review the “users” table, if “accountNonLocked” = 0 or false, means this user is in locked status.

spring-security-limit-login-attempts-locked-database

ソースコードをダウンロード

ダウンロード–spring-security-limit-login-annotation.zip(38 KB)

ダウンロード–spring-security-limit-login-xml.zip(32 KB)