春のセキュリティ:ログイン試行の制限の例
このチュートリアルでは、Spring Securityのログイン試行を制限する方法を示します。つまり、ユーザーが無効なパスワードで3回以上ログインしようとすると、システムはユーザーをロックしてログインできなくなります。
使用される技術とツール:
-
Spring 3.2.8.RELEASE
-
春のセキュリティ3.2.3.RELEASE
-
Spring JDBC 3.2.3.RELEASE
-
Eclipse 4.2
-
JDK 1.6
-
Maven 3
-
MySQLサーバ5.6
-
Tomcat 7(Servlet 3.x)
このチュートリアルのいくつかの簡単なメモ:
-
MySQLデータベースが使用されます.
-
これはSpring Securityのアノテーションに基づいた例です.
-
列「accountNonLocked」を持つ「ユーザー」表を作成します.
-
無効なログイン試行を保存するための “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.データベース
ここに、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. UserDetailsService
デフォルトでは、
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`を得ることができます。
CustomUserDetailsService.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)
参考文献
: How can I limit login attempts in Spring Security?].
http://docs.spring.io/spring-security/site/docs/3.2.3.RELEASE/reference/htmlsingle/#core-services
[Spring
公式リファレンス:Core Services – authenticationProvider]。リンク://spring-security/spring-security-custom-login-form-annotation-example/[Spring
セキュリティカスタムログインフォーム注釈の例]。
http://docs.spring.io/spring-security/site/docs/3.2.3.RELEASE/apidocs/org/springframework/security/authentication/dao/DaoAuthenticationProvider.html
[Spring
DaoAuthenticationProvider JavaDoc]。
http://docs.spring.io/spring-security/site/docs/3.2.3.RELEASE/apidocs/org/springframework/security/core/userdetails/jdbc/JdbcDaoImpl.html
[Spring
JdbcDaoImpl JavaDoc]