序
对于登录功能来说,为了防止暴力破解密码,一般会对登录失败次数进行限定,在一定时间窗口超过一定次数,则锁定账户,来确保系统安全。本文主要讲述一下spring security的账户锁定。
UserDetails
spring-security-core-4.2.3.RELEASE-sources.jar!/org/springframework/security/core/userdetails/UserDetails.java
/** * Provides core user information. * ** Implementations are not used directly by Spring Security for security purposes. They * simply store user information which is later encapsulated into {@link Authentication} * objects. This allows non-security related user information (such as email addresses, * telephone numbers etc) to be stored in a convenient location. *
* Concrete implementations must take particular care to ensure the non-null contract * detailed for each method is enforced. See * {@link org.springframework.security.core.userdetails.User} for a reference * implementation (which you might like to extend or use in your code). * * @see UserDetailsService * @see UserCache * * @author Ben Alex */public interface UserDetails extends Serializable { // ~ Methods // ======================================================================================================== /** * Returns the authorities granted to the user. Cannot return
getAuthorities(); /** * Returns the password used to authenticate the user. * * @return the password */ String getPassword(); /** * Returns the username used to authenticate the user. Cannot returnnull
. * * @return the authorities, sorted by natural key (nevernull
) */ Collectionnull
* . * * @return the username (nevernull
) */ String getUsername(); /** * Indicates whether the user's account has expired. An expired account cannot be * authenticated. * * @returntrue
if the user's account is valid (ie non-expired), *false
if no longer valid (ie expired) */ boolean isAccountNonExpired(); /** * Indicates whether the user is locked or unlocked. A locked user cannot be * authenticated. * * @returntrue
if the user is not locked,false
otherwise */ boolean isAccountNonLocked(); /** * Indicates whether the user's credentials (password) has expired. Expired * credentials prevent authentication. * * @returntrue
if the user's credentials are valid (ie non-expired), *false
if no longer valid (ie expired) */ boolean isCredentialsNonExpired(); /** * Indicates whether the user is enabled or disabled. A disabled user cannot be * authenticated. * * @returntrue
if the user is enabled,false
otherwise */ boolean isEnabled();}
spring security的UserDetails内置了isAccountNonLocked方法来判断账户是否被锁定
AbstractUserDetailsAuthenticationProvider#authenticate
spring-security-core-4.2.3.RELEASE-sources.jar!/org/springframework/security/authentication/dao/AbstractUserDetailsAuthenticationProvider.java
public abstract class AbstractUserDetailsAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware { protected MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor(); private UserCache userCache = new NullUserCache(); private boolean forcePrincipalAsString = false; protected boolean hideUserNotFoundExceptions = true; private UserDetailsChecker preAuthenticationChecks = new DefaultPreAuthenticationChecks(); private UserDetailsChecker postAuthenticationChecks = new DefaultPostAuthenticationChecks(); private GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper(); public Authentication authenticate(Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, messages.getMessage( "AbstractUserDetailsAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported")); // Determine username String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED" : authentication.getName(); boolean cacheWasUsed = true; UserDetails user = this.userCache.getUserFromCache(username); if (user == null) { cacheWasUsed = false; try { user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); } catch (UsernameNotFoundException notFound) { logger.debug("User '" + username + "' not found"); if (hideUserNotFoundExceptions) { throw new BadCredentialsException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.badCredentials", "Bad credentials")); } else { throw notFound; } } Assert.notNull(user, "retrieveUser returned null - a violation of the interface contract"); } try { preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } catch (AuthenticationException exception) { if (cacheWasUsed) { // There was a problem, so try again after checking // we're using latest data (i.e. not from the cache) cacheWasUsed = false; user = retrieveUser(username, (UsernamePasswordAuthenticationToken) authentication); preAuthenticationChecks.check(user); additionalAuthenticationChecks(user, (UsernamePasswordAuthenticationToken) authentication); } else { throw exception; } } postAuthenticationChecks.check(user); if (!cacheWasUsed) { this.userCache.putUserInCache(user); } Object principalToReturn = user; if (forcePrincipalAsString) { principalToReturn = user.getUsername(); } return createSuccessAuthentication(principalToReturn, authentication, user); } //......}
AbstractUserDetailsAuthenticationProvider的authenticate里头内置了preAuthenticationChecks和postAuthenticationChecks,而preAuthenticationChecks使用的是DefaultPreAuthenticationChecks
默认的DaoAuthenticationProvider继承自AbstractUserDetailsAuthenticationProvider
AbstractUserDetailsAuthenticationProvider#DefaultPreAuthenticationChecks
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")); } if (!user.isEnabled()) { logger.debug("User account is disabled"); throw new DisabledException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.disabled", "User is disabled")); } if (!user.isAccountNonExpired()) { logger.debug("User account is expired"); throw new AccountExpiredException(messages.getMessage( "AbstractUserDetailsAuthenticationProvider.expired", "User account has expired")); } } }
这里会对账户的isAccountNonLocked进行判断,如果被锁定,则在登录的时候,抛出LockedException
实现账户锁定
实现大致思路就是基于用户登录失败次数进行时间窗口统计,超过阈值则将用户的isAccountNonLocked设置为true,那么在下次登录时,则会抛出LockedException。
这里基于AuthenticationFailureBadCredentialsEvent事件来实现 时间窗口统计使用ratelimitj-inmemory组件
es.moki.ratelimitj ratelimitj-inmemory 0.4.1
分布式场景可以替换为基于redis实现
AuthenticationFailureBadCredentialsEvent
在登录失败的时候,spring security会抛出AuthenticationFailureBadCredentialsEvent事件,基于事件监听机制,可以实现
@Componentpublic class LoginFailureListener implements ApplicationListener{ private static final Logger LOGGER = LoggerFactory.getLogger(LoginFailureListener.class); //错误了第四次返回true,然后锁定账号,第五次即使密码正确也会报账户锁定 Set rules = Collections.singleton(RequestLimitRule.of(10, TimeUnit.MINUTES,3)); // 3 request per 10 minute, per key RequestRateLimiter limiter = new InMemorySlidingWindowRequestRateLimiter(rules); @Autowired UserDetailsManager userDetailsManager; @Override public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent event) { if (event.getException().getClass().equals(UsernameNotFoundException.class)) { return; } String userId = event.getAuthentication().getName(); boolean reachLimit = limiter.overLimitWhenIncremented(userId); if(reachLimit){ User user = (User) userDetailsManager.loadUserByUsername(userId); LOGGER.info("user:{} is locked",user); User updated = new User(user.getUsername(),user.getPassword(),user.isEnabled(),user.isAccountNonExpired(),user.isAccountNonExpired(),false,user.getAuthorities()); userDetailsManager.updateUser(updated); } }}
这里排除了用户名错误的情况。然后每失败一次,就进行时间窗口统计,如果超出阈值,则立马更新用户的accountNonLocked属性。那么第四次输错密码时,user的accountNonLocked属性被更新为false,之后第五次无论密码对错,则会抛出LockedException
上面的方案,还需要在时间窗口之后重置这个accountNonLocked属性,这里没有实现。
小结
spring security还是蛮强大的,在AbstractUserDetailsAuthenticationProvider的authenticate里头内置了preAuthenticationChecks,帮你建立关于登录前的各种预校验。具体的实现就交给应用层。