Spring Security:ログイン試行の制限の例
このチュートリアルでは、Spring Securityのログイン試行を制限する方法を示します。つまり、ユーザーが無効なパスワードで3回以上ログインしようとすると、システムはユーザーをロックし、ログインできなくなります。
使用される技術とツール:
-
Spring 3.2.8.RELEASE
-
Spring Security 3.2.3.RELEASE
-
Spring JDBC 3.2.3.RELEASE
-
Eclipse 4.2
-
JDK 1.6
-
メーベン3
-
MySQLサーバー5.6
-
Tomcat 7(サーブレット3.x)
このチュートリアルの簡単なメモ:
-
MySQLデータベースが使用されます。
-
これは、Spring Securityの注釈ベースの例です。
-
列「accountNonLocked」を持つ「users」テーブルを作成します。
-
「user_attempts」テーブルを作成して、無効なログイン試行を保存します。
-
Spring JDBCが使用されます。
-
返された例外に基づいてカスタムエラーメッセージを表示します。
-
カスタマイズされた「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. データベース
以下は、ユーザー、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. UserDetailsService
デフォルトでは、JdbcDaoImpl
は常にaccountNonLocked
をtrueに設定しますが、これは私たちが望んでいることではありません。 ソースコードを確認します。
JdbcDaoImpl.java
package org.springframework.security.core.userdetails.jdbc; public class JdbcDaoImpl extends JdbcDaoSupport implements UserDetailsService { //... protected ListloadUsersByUsername(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
を拡張し、loadUsersByUsername
とcreateUserDetails
の両方をオーバーライドして、カスタマイズされた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 ListloadUsersByUsername(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.
11.2, If the maximum number of invalid login attempts are hit, error message “User account is locked” will be displayed.
11.3, If user in “locked” status, and still try to login again. ロックされた詳細が表示されます。
11.4 Review the “users” table, if “accountNonLocked” = 0 or false, means this user is in locked status.
ソースコードをダウンロード
ダウンロード–spring-security-limit-login-annotation.zip(38 KB)
ダウンロード–spring-security-limit-login-xml.zip(32 KB)