聊聊spring security的账户锁定
序
对于登录功能来说,为了防止暴力破解密码,一般会对登录失败次数进行限定,在一定时间窗口超过一定次数,则锁定账户,来确保系统安全。本文主要讲述一下spring security的账户锁定。
UserDetails
spring-security-core-4.2.3.RELEASE-sources.jar!/org/springframework/security/core/userdetails/UserDetails.java
/**
* Provides core user information.
*
* <p>
* 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.
* <p>
* 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 <code>null</code>.
*
* @return the authorities, sorted by natural key (never <code>null</code>)
*/
Collection<? extends GrantedAuthority> getAuthorities();
/**
* Returns the password used to authenticate the user.
*
* @return the password
*/
String getPassword();
/**
* Returns the username used to authenticate the user. Cannot return <code>null</code>
* .
*
* @return the username (never <code>null</code>)
*/
String getUsername();
/**
* Indicates whether the user's account has expired. An expired account cannot be
* authenticated.
*
* @return <code>true</code> if the user's account is valid (ie non-expired),
* <code>false</code> if no longer valid (ie expired)
*/
boolean isAccountNonExpired();
/**
* Indicates whether the user is locked or unlocked. A locked user cannot be
* authenticated.
*
* @return <code>true</code> if the user is not locked, <code>false</code> otherwise
*/
boolean isAccountNonLocked();
/**
* Indicates whether the user's credentials (password) has expired. Expired
* credentials prevent authentication.
*
* @return <code>true</code> if the user's credentials are valid (ie non-expired),
* <code>false</code> if no longer valid (ie expired)
*/
boolean isCredentialsNonExpired();
/**
* Indicates whether the user is enabled or disabled. A disabled user cannot be
* authenticated.
*
* @return <code>true</code> if the user is enabled, <code>false</code> 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组件
<dependency>
<groupId>es.moki.ratelimitj</groupId>
<artifactId>ratelimitj-inmemory</artifactId>
<version>0.4.1</version>
</dependency>
分布式场景可以替换为基于redis实现
AuthenticationFailureBadCredentialsEvent
在登录失败的时候,spring security会抛出AuthenticationFailureBadCredentialsEvent事件,基于事件监听机制,可以实现
@Component
public class LoginFailureListener implements ApplicationListener<AuthenticationFailureBadCredentialsEvent> {
private static final Logger LOGGER = LoggerFactory.getLogger(LoginFailureListener.class);
//错误了第四次返回true,然后锁定账号,第五次即使密码正确也会报账户锁定
Set<RequestLimitRule> 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,帮你建立关于登录前的各种预校验。具体的实现就交给应用层。