開発者ドキュメント

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


spring-security-limit-login-attempts-locked、width = 525、height = 480

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

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

  1. Spring 3.2.8.RELEASE

  2. 春のセキュリティ3.2.3.RELEASE

  3. Spring JDBC 3.2.3.RELEASE

  4. Eclipse 4.2

  5. JDK 1.6

  6. Maven 3

  7. MySQLサーバ5.6

  8. Tomcat 7(Servlet 3.x)

このチュートリアルのいくつかの簡単なメモ:

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

  2. これはSpring Securityのアノテーションに基づいた例です.

  3. 列「accountNonLocked」を持つ「ユーザー」表を作成します.

  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.プロジェクトディレクトリ

最終的なプロジェクト構造を見直す(注釈ベース):



4.データベース

ここに、users、user

roles、およびuser

attemptsテーブルを作成するためのMySQLスクリプトがあります。

4.1カラム “accountNonLocked”を持つ “users”テーブルを作成します。

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 “user__roles”テーブルを作成します。

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 “user__attempts”テーブルを作成します。

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テスト用のユーザーを挿入します。

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

INSERT INTO user__roles (username, role)
VALUES ('mkyong', 'ROLE__USER');
INSERT INTO user__roles (username, role)
VALUES ('mkyong', 'ROLE__ADMIN');

5. UserAttemptsクラス

このクラスは、 “user__attempts”テーブルのデータを表します。

UserAttempts.java

package com.mkyong.users.model;

import java.util.Date;

public class UserAttempts {

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

   //getter and setter

}

DAOクラス

無効なログイン試行を更新するためのDAOクラスは、コメントを読んでください。

UserDetailsDao.java

package com.mkyong.users.dao;

import com.mkyong.users.model.UserAttempts;

public interface UserDetailsDao {

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

}

UserDetailsDaoImpl.java

package com.mkyong.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.mkyong.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<UserAttempts>() {
            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<UserDetails> loadUsersByUsername(String username) {
    return getJdbcTemplate().query(usersByUsernameQuery, new String[]{username}, new RowMapper<UserDetails>() {
      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`を拡張し、

loadUsersByUsername`と

createUserDetails`の両方をオーバーライドしてカスタマイズされた

UserDetails`を得ることができます。

CustomUserDetailsS​​ervice.java

package com.mkyong.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 mkyong
 **
 ** /@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<UserDetails> loadUsersByUsername(String username) {
      return getJdbcTemplate().query(super.getUsersByUsernameQuery(), new String[]{ username },
        new RowMapper<UserDetails>() {
          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<GrantedAuthority> 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.mkyong.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.mkyong.users.dao.UserDetailsDao;
import com.mkyong.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! <br><br>Username : "
                           + authentication.getName() + "<br>Last Attempts : " + lastAttempts;
        }else{
            error = e.getMessage();
        }

      throw new LockedException(error);
    }

    }

}

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

標準のコントローラクラスで、 `login`メソッドを参照して、セッション値 – ”

SPRING

SECURITY

LAST__EXCEPTION

“の使い方を示し、エラーメッセージをカスタマイズします。

MainController.java

package com.mkyong.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.春のセキュリティ設定

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

SecurityConfig.java

package com.mkyong.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.デモ

11.1、最初の無効なログイン試行では、通常のエラーメッセージが表示されます。



11.2、無効なログイン試行の最大回数がヒットした場合、エラーメッセージ “ユーザーアカウントがロックされています”が表示されます。



11.3、ユーザーが「ロックされた」状態で、まだログインを再試行している場合。ロックされた詳細が表示されます。



11.4「ユーザー」テーブルを見直してください。「accountNonLocked」= 0またはfalseの場合、このユーザーはロックされた状態です。



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

ダウンロードする – リンク://wp-content/uploads/2014/04/spring-security-limit-login-annotation.zip[spring-security-limit-login-annotation.zip](38 KB)

ダウンロードする –

spring-security-limit-login-xml.zip

(32 KB)

参考文献

公式リファレンス:Core Services – authenticationProvider]。リンク://spring-security/spring-security-custom-login-form-annotation-example/[Spring

JdbcDaoImpl JavaDoc]

モバイルバージョンを終了